simdra 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/core/index.d.ts +385 -0
- package/dist/core/index.js +1913 -0
- package/dist/core/microsharp/index.d.ts +546 -0
- package/dist/core/microsharp/index.js +1751 -0
- package/dist/core/node-zigar-addon/darwin.arm64.node +0 -0
- package/dist/core/node-zigar-addon/darwin.x64.node +0 -0
- package/dist/core/node-zigar-addon/linux-musl.arm64.node +0 -0
- package/dist/core/node-zigar-addon/linux-musl.x64.node +0 -0
- package/dist/core/node-zigar-addon/linux.arm64.node +0 -0
- package/dist/core/node-zigar-addon/linux.x64.node +0 -0
- package/dist/core/node-zigar-addon/win32.x64.node +0 -0
- package/dist/core/simdra.zigar/darwin.arm64.dylib +0 -0
- package/dist/core/simdra.zigar/darwin.x64.dylib +0 -0
- package/dist/core/simdra.zigar/linux-musl.arm64.so +0 -0
- package/dist/core/simdra.zigar/linux-musl.x64.so +0 -0
- package/dist/core/simdra.zigar/linux.arm64.so +0 -0
- package/dist/core/simdra.zigar/linux.x64.so +0 -0
- package/dist/core/simdra.zigar/win32.x64.dll +0 -0
- package/dist/core/zig.js +37025 -0
- package/dist/wasm/index.mjs +43921 -0
- package/dist/wasm/simdra.wasm +0 -0
- package/package.json +102 -0
|
@@ -0,0 +1,1913 @@
|
|
|
1
|
+
// Public API of simdra — HTML5 / WebIDL classes.
|
|
2
|
+
//
|
|
3
|
+
// **Design rule:** every public class is a TypeScript class that holds a
|
|
4
|
+
// PRIVATE handle to its underlying Zig proxy. Consumers see only the
|
|
5
|
+
// HTML5 spec surface; the Sm* Zig types from `./zig.js` are
|
|
6
|
+
// strictly internal — they never leave this module. Cross-class internal
|
|
7
|
+
// access goes through the module-private `ZIG` Symbol so wrappers can
|
|
8
|
+
// hand each other their underlying handles (e.g. `ctx.putImageData(bm)`)
|
|
9
|
+
// without those handles leaking to user code.
|
|
10
|
+
//
|
|
11
|
+
// **Memory cleanup:** Zig types own page-allocator buffers that node-zigar
|
|
12
|
+
// does not GC. We register every wrapper with a `FinalizationRegistry`
|
|
13
|
+
// so when the JS object becomes unreachable, the Zig buffer is freed.
|
|
14
|
+
// Consumers never call `.deinit()` or `.releaseImageData()` — those are
|
|
15
|
+
// gone from the public API.
|
|
16
|
+
//
|
|
17
|
+
// Modeled after Skia's C++ surface (`SkCanvas`, `SkBitmap`, `SkPath`,
|
|
18
|
+
// `SkMatrix`) wrapped by Chromium's HTML5 implementation, and
|
|
19
|
+
// canvas-rs / @napi-rs/canvas which take the same wrapping approach.
|
|
20
|
+
import * as zig from './zig.js';
|
|
21
|
+
import { SmSurface, SmCanvas as SmCanvasZig, SmBitmap, SmMatrix, SmPath, SmGradient, SmPattern, SmFont, defaultFontBytes, parseCssColor, encodePngAsync, encodeJpegAsync, } from './zig.js';
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Internal: cross-wrapper handle access
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Module-private symbols — wrappers store their underlying Zig proxy and
|
|
27
|
+
* provide internal-construction factories. Same-module code can read them;
|
|
28
|
+
* consumers cannot, because they don't have the symbol reference.
|
|
29
|
+
*/
|
|
30
|
+
const ZIG = Symbol('zig');
|
|
31
|
+
const FROM_ZIG = Symbol('fromZig');
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Internal: finalization registries
|
|
34
|
+
// =============================================================================
|
|
35
|
+
//
|
|
36
|
+
// Each wrapper class registers itself with a registry that frees its Zig
|
|
37
|
+
// buffer when the wrapper is GC'd. Callbacks are async/best-effort per the
|
|
38
|
+
// FinalizationRegistry contract — fine for memory correctness.
|
|
39
|
+
const bitmapRegistry = new FinalizationRegistry((bitmap) => {
|
|
40
|
+
SmBitmap.release(bitmap);
|
|
41
|
+
});
|
|
42
|
+
const pathRegistry = new FinalizationRegistry((path) => {
|
|
43
|
+
path.deinit();
|
|
44
|
+
});
|
|
45
|
+
const gradientRegistry = new FinalizationRegistry((g) => {
|
|
46
|
+
g.deinit();
|
|
47
|
+
});
|
|
48
|
+
const patternRegistry = new FinalizationRegistry((p) => {
|
|
49
|
+
p.deinit();
|
|
50
|
+
});
|
|
51
|
+
const surfaceRegistry = new FinalizationRegistry((s) => {
|
|
52
|
+
s.deinit();
|
|
53
|
+
});
|
|
54
|
+
export class ImageData {
|
|
55
|
+
/** @internal */ [ZIG];
|
|
56
|
+
constructor(arg1, arg2, arg3, arg4) {
|
|
57
|
+
if (arg1 === undefined || arg2 === undefined) {
|
|
58
|
+
throw new TypeError('ImageData requires at least 2 arguments');
|
|
59
|
+
}
|
|
60
|
+
let bitmap;
|
|
61
|
+
if (typeof arg1 === 'number') {
|
|
62
|
+
const w = arg1;
|
|
63
|
+
const h = arg2;
|
|
64
|
+
const settings = arg3 ?? {};
|
|
65
|
+
bitmap = SmBitmap.createBlank(w, h, settings);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const data = arg1;
|
|
69
|
+
const w = arg2;
|
|
70
|
+
const h = typeof arg3 === 'number' ? arg3 : null;
|
|
71
|
+
const settings = (typeof arg3 === 'number' ? arg4 : arg3) ?? {};
|
|
72
|
+
bitmap = SmBitmap.createFromBuffer(data, w, h, settings);
|
|
73
|
+
}
|
|
74
|
+
this[ZIG] = bitmap;
|
|
75
|
+
bitmapRegistry.register(this, bitmap, this);
|
|
76
|
+
}
|
|
77
|
+
/** @internal — wrap an existing Zig bitmap (used by getImageData). */
|
|
78
|
+
static [FROM_ZIG](bitmap) {
|
|
79
|
+
const obj = Object.create(ImageData.prototype);
|
|
80
|
+
obj[ZIG] = bitmap;
|
|
81
|
+
bitmapRegistry.register(obj, bitmap, obj);
|
|
82
|
+
return obj;
|
|
83
|
+
}
|
|
84
|
+
get data() {
|
|
85
|
+
return this[ZIG].data;
|
|
86
|
+
}
|
|
87
|
+
get width() {
|
|
88
|
+
return this[ZIG].width;
|
|
89
|
+
}
|
|
90
|
+
get height() {
|
|
91
|
+
return this[ZIG].height;
|
|
92
|
+
}
|
|
93
|
+
get colorSpace() {
|
|
94
|
+
return String(this[ZIG].colorSpace);
|
|
95
|
+
}
|
|
96
|
+
get pixelFormat() {
|
|
97
|
+
return String(this[ZIG].pixelFormat);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Image — decoded image source (PNG / JPEG / BMP / GIF first frame)
|
|
102
|
+
// =============================================================================
|
|
103
|
+
//
|
|
104
|
+
// HTMLImageElement-shaped helper for Node / WASM environments. Construction
|
|
105
|
+
// is via the `Image.fromBytes(bytes)` factory (not `new Image(src); img.src=...`
|
|
106
|
+
// — there is no async loader on the JS side; bytes go in synchronously).
|
|
107
|
+
//
|
|
108
|
+
// `Image` carries a private `SmBitmap` handle and is consumed by
|
|
109
|
+
// `ctx.drawImage(image, ...)` and `ctx.createPattern(image, rep)`. Unlike
|
|
110
|
+
// `ImageData`, it does NOT expose a mutable `data` array — it is an opaque
|
|
111
|
+
// image source. Use `ctx.drawImage(...)` (which respects the CTM /
|
|
112
|
+
// compositing) rather than the raw-pixel `putImageData` path.
|
|
113
|
+
//
|
|
114
|
+
// The underlying `SmBitmap` is freed via the existing `bitmapRegistry`
|
|
115
|
+
// when the JS object is GC'd.
|
|
116
|
+
export class Image {
|
|
117
|
+
/** @internal */ [ZIG];
|
|
118
|
+
constructor() {
|
|
119
|
+
throw new TypeError('Image: use Image.fromBytes(bytes)');
|
|
120
|
+
}
|
|
121
|
+
/** Decode PNG / JPEG / BMP / GIF (first frame) bytes into an Image. */
|
|
122
|
+
static fromBytes(bytes) {
|
|
123
|
+
const view = bytes;
|
|
124
|
+
const bitmap = SmBitmap.decode(view);
|
|
125
|
+
const obj = Object.create(Image.prototype);
|
|
126
|
+
obj[ZIG] = bitmap;
|
|
127
|
+
bitmapRegistry.register(obj, bitmap, obj);
|
|
128
|
+
return obj;
|
|
129
|
+
}
|
|
130
|
+
/** @internal — wrap an existing Zig bitmap (used by the sharp-shaped
|
|
131
|
+
* binding's pipeline so it can hand over a decoded buffer). */
|
|
132
|
+
static [FROM_ZIG](bitmap) {
|
|
133
|
+
const obj = Object.create(Image.prototype);
|
|
134
|
+
obj[ZIG] = bitmap;
|
|
135
|
+
bitmapRegistry.register(obj, bitmap, obj);
|
|
136
|
+
return obj;
|
|
137
|
+
}
|
|
138
|
+
get width() {
|
|
139
|
+
return this[ZIG].width;
|
|
140
|
+
}
|
|
141
|
+
get height() {
|
|
142
|
+
return this[ZIG].height;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// HTML5: DOMMatrix (2D affine subset)
|
|
147
|
+
// =============================================================================
|
|
148
|
+
//
|
|
149
|
+
// Per spec:
|
|
150
|
+
// new DOMMatrix() — identity
|
|
151
|
+
// new DOMMatrix(init: number[6 | 16] | string)
|
|
152
|
+
// 16-element column-major layout per WebIDL:
|
|
153
|
+
// [m11 m12 m13 m14, m21 m22 m23 m24, m31 m32 m33 m34, m41 m42 m43 m44]
|
|
154
|
+
// For a 2D matrix the 3D-position values must equal identity, otherwise
|
|
155
|
+
// reject — we don't model 3D matrices.
|
|
156
|
+
function matrixFrom16(arr) {
|
|
157
|
+
const ok = arr[2] === 0 && arr[3] === 0 &&
|
|
158
|
+
arr[6] === 0 && arr[7] === 0 &&
|
|
159
|
+
arr[8] === 0 && arr[9] === 0 && arr[10] === 1 && arr[11] === 0 &&
|
|
160
|
+
arr[14] === 0 && arr[15] === 1;
|
|
161
|
+
if (!ok) {
|
|
162
|
+
throw new TypeError('DOMMatrix: 16-element init must encode a 2D matrix');
|
|
163
|
+
}
|
|
164
|
+
return SmMatrix.components(arr[0], arr[1], arr[4], arr[5], arr[12], arr[13]);
|
|
165
|
+
}
|
|
166
|
+
function matrixFromTypedArray(arr) {
|
|
167
|
+
if (arr.length === 6) {
|
|
168
|
+
return SmMatrix.components(arr[0], arr[1], arr[2], arr[3], arr[4], arr[5]);
|
|
169
|
+
}
|
|
170
|
+
if (arr.length === 16) {
|
|
171
|
+
return matrixFrom16(arr);
|
|
172
|
+
}
|
|
173
|
+
throw new TypeError('DOMMatrix: typed array must have 6 or 16 elements');
|
|
174
|
+
}
|
|
175
|
+
export class DOMMatrix {
|
|
176
|
+
/** @internal */ [ZIG];
|
|
177
|
+
constructor(init) {
|
|
178
|
+
if (init === undefined) {
|
|
179
|
+
this[ZIG] = SmMatrix.identity();
|
|
180
|
+
}
|
|
181
|
+
else if (typeof init === 'string') {
|
|
182
|
+
throw new Error('DOMMatrix: SVG transform-string init not supported');
|
|
183
|
+
}
|
|
184
|
+
else if (Array.isArray(init) && init.length === 6) {
|
|
185
|
+
const [a, b, c, d, e, f] = init;
|
|
186
|
+
this[ZIG] = SmMatrix.components(a, b, c, d, e, f);
|
|
187
|
+
}
|
|
188
|
+
else if (Array.isArray(init) && init.length === 16) {
|
|
189
|
+
this[ZIG] = matrixFrom16(init);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
throw new TypeError('DOMMatrix: only 6- or 16-element init array supported');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** @internal */
|
|
196
|
+
static [FROM_ZIG](zig) {
|
|
197
|
+
const obj = Object.create(DOMMatrix.prototype);
|
|
198
|
+
obj[ZIG] = zig;
|
|
199
|
+
return obj;
|
|
200
|
+
}
|
|
201
|
+
static fromFloat32Array(arr) {
|
|
202
|
+
return DOMMatrix[FROM_ZIG](matrixFromTypedArray(arr));
|
|
203
|
+
}
|
|
204
|
+
static fromFloat64Array(arr) {
|
|
205
|
+
return DOMMatrix[FROM_ZIG](matrixFromTypedArray(arr));
|
|
206
|
+
}
|
|
207
|
+
static fromMatrix(other) {
|
|
208
|
+
if (other === undefined)
|
|
209
|
+
return new DOMMatrix();
|
|
210
|
+
if (other instanceof DOMMatrix) {
|
|
211
|
+
return DOMMatrix[FROM_ZIG](SmMatrix.components(other.a, other.b, other.c, other.d, other.e, other.f));
|
|
212
|
+
}
|
|
213
|
+
const o = other;
|
|
214
|
+
const a = o.a ?? o.m11 ?? 1;
|
|
215
|
+
const b = o.b ?? o.m12 ?? 0;
|
|
216
|
+
const c = o.c ?? o.m21 ?? 0;
|
|
217
|
+
const d = o.d ?? o.m22 ?? 1;
|
|
218
|
+
const e = o.e ?? o.m41 ?? 0;
|
|
219
|
+
const f = o.f ?? o.m42 ?? 0;
|
|
220
|
+
return DOMMatrix[FROM_ZIG](SmMatrix.components(a, b, c, d, e, f));
|
|
221
|
+
}
|
|
222
|
+
get a() { return this[ZIG].a; }
|
|
223
|
+
get b() { return this[ZIG].b; }
|
|
224
|
+
get c() { return this[ZIG].c; }
|
|
225
|
+
get d() { return this[ZIG].d; }
|
|
226
|
+
get e() { return this[ZIG].e; }
|
|
227
|
+
get f() { return this[ZIG].f; }
|
|
228
|
+
set a(v) { this[ZIG].a = v; }
|
|
229
|
+
set b(v) { this[ZIG].b = v; }
|
|
230
|
+
set c(v) { this[ZIG].c = v; }
|
|
231
|
+
set d(v) { this[ZIG].d = v; }
|
|
232
|
+
set e(v) { this[ZIG].e = v; }
|
|
233
|
+
set f(v) { this[ZIG].f = v; }
|
|
234
|
+
// 4×4 aliases for the 2D positions (per WebIDL, m11..m42 alias a..f).
|
|
235
|
+
get m11() { return this[ZIG].a; }
|
|
236
|
+
get m12() { return this[ZIG].b; }
|
|
237
|
+
get m21() { return this[ZIG].c; }
|
|
238
|
+
get m22() { return this[ZIG].d; }
|
|
239
|
+
get m41() { return this[ZIG].e; }
|
|
240
|
+
get m42() { return this[ZIG].f; }
|
|
241
|
+
set m11(v) { this[ZIG].a = v; }
|
|
242
|
+
set m12(v) { this[ZIG].b = v; }
|
|
243
|
+
set m21(v) { this[ZIG].c = v; }
|
|
244
|
+
set m22(v) { this[ZIG].d = v; }
|
|
245
|
+
set m41(v) { this[ZIG].e = v; }
|
|
246
|
+
set m42(v) { this[ZIG].f = v; }
|
|
247
|
+
// 3D-only positions — this implementation is forced-2D, so they read as
|
|
248
|
+
// identity values and have no setters.
|
|
249
|
+
get m13() { return 0; }
|
|
250
|
+
get m14() { return 0; }
|
|
251
|
+
get m23() { return 0; }
|
|
252
|
+
get m24() { return 0; }
|
|
253
|
+
get m31() { return 0; }
|
|
254
|
+
get m32() { return 0; }
|
|
255
|
+
get m33() { return 1; }
|
|
256
|
+
get m34() { return 0; }
|
|
257
|
+
get m43() { return 0; }
|
|
258
|
+
get m44() { return 1; }
|
|
259
|
+
get is2D() { return true; }
|
|
260
|
+
get isIdentity() {
|
|
261
|
+
const m = this[ZIG];
|
|
262
|
+
return m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0;
|
|
263
|
+
}
|
|
264
|
+
multiplySelf(other) {
|
|
265
|
+
this[ZIG].multiplySelf(other[ZIG]);
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
preMultiplySelf(other) {
|
|
269
|
+
this[ZIG].preMultiplySelf(other[ZIG]);
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
translateSelf(tx, ty) {
|
|
273
|
+
this[ZIG].translateSelf(tx, ty);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
scaleSelf(sx, sy) {
|
|
277
|
+
this[ZIG].scaleSelf(sx, sy);
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
rotateSelf(angleDegrees) {
|
|
281
|
+
this[ZIG].rotateSelf(angleDegrees);
|
|
282
|
+
return this;
|
|
283
|
+
}
|
|
284
|
+
rotateFromVectorSelf(x, y) {
|
|
285
|
+
const angle = (x === 0 && y === 0) ? 0 : Math.atan2(y, x) * (180 / Math.PI);
|
|
286
|
+
this[ZIG].rotateSelf(angle);
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
rotateAxisAngleSelf(x, y, z, angleDegrees) {
|
|
290
|
+
if (x === 0 && y === 0 && z > 0) {
|
|
291
|
+
this[ZIG].rotateSelf(angleDegrees);
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
294
|
+
throw new Error('DOMMatrix.rotateAxisAngleSelf: only positive z-axis rotation supported in 2D');
|
|
295
|
+
}
|
|
296
|
+
scale3dSelf(scale, originX = 0, originY = 0, originZ = 0) {
|
|
297
|
+
if (originZ !== 0) {
|
|
298
|
+
throw new Error('DOMMatrix.scale3dSelf: 3D origin not supported');
|
|
299
|
+
}
|
|
300
|
+
if (originX === 0 && originY === 0) {
|
|
301
|
+
this[ZIG].scaleSelf(scale, scale);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
this[ZIG].translateSelf(originX, originY);
|
|
305
|
+
this[ZIG].scaleSelf(scale, scale);
|
|
306
|
+
this[ZIG].translateSelf(-originX, -originY);
|
|
307
|
+
}
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
skewXSelf(angleDegrees) {
|
|
311
|
+
this[ZIG].skewXSelf(angleDegrees);
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
314
|
+
skewYSelf(angleDegrees) {
|
|
315
|
+
this[ZIG].skewYSelf(angleDegrees);
|
|
316
|
+
return this;
|
|
317
|
+
}
|
|
318
|
+
invertSelf() {
|
|
319
|
+
this[ZIG].invertSelf();
|
|
320
|
+
return this;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// =============================================================================
|
|
324
|
+
// HTML5: Path2D
|
|
325
|
+
// =============================================================================
|
|
326
|
+
export class Path2D {
|
|
327
|
+
/** @internal */ [ZIG];
|
|
328
|
+
constructor(other) {
|
|
329
|
+
let p;
|
|
330
|
+
if (other === undefined) {
|
|
331
|
+
p = SmPath.empty();
|
|
332
|
+
}
|
|
333
|
+
else if (typeof other === 'string') {
|
|
334
|
+
throw new Error('Path2D: SVG path-data string constructor not supported');
|
|
335
|
+
}
|
|
336
|
+
else if (other instanceof Path2D) {
|
|
337
|
+
p = other[ZIG].copy();
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
throw new TypeError('Path2D: expected Path2D or undefined');
|
|
341
|
+
}
|
|
342
|
+
this[ZIG] = p;
|
|
343
|
+
pathRegistry.register(this, p, this);
|
|
344
|
+
}
|
|
345
|
+
closePath() { this[ZIG].closePath(); }
|
|
346
|
+
moveTo(x, y) { this[ZIG].moveTo(x, y); }
|
|
347
|
+
lineTo(x, y) { this[ZIG].lineTo(x, y); }
|
|
348
|
+
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) {
|
|
349
|
+
this[ZIG].bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
350
|
+
}
|
|
351
|
+
quadraticCurveTo(cpx, cpy, x, y) {
|
|
352
|
+
this[ZIG].quadraticCurveTo(cpx, cpy, x, y);
|
|
353
|
+
}
|
|
354
|
+
rect(x, y, w, h) {
|
|
355
|
+
this[ZIG].rect(x, y, w, h);
|
|
356
|
+
}
|
|
357
|
+
arc(cx, cy, r, startAngle, endAngle, counterclockwise = false) {
|
|
358
|
+
this[ZIG].arc(cx, cy, r, startAngle, endAngle, counterclockwise);
|
|
359
|
+
}
|
|
360
|
+
arcTo(x1, y1, x2, y2, r) {
|
|
361
|
+
if (typeof r === 'number' && isFinite(r) && r < 0) {
|
|
362
|
+
throw new DOMException('arcTo: negative radius', 'IndexSizeError');
|
|
363
|
+
}
|
|
364
|
+
this[ZIG].arcTo(x1, y1, x2, y2, r);
|
|
365
|
+
}
|
|
366
|
+
roundRect(x, y, w, h, radii) {
|
|
367
|
+
const rs = normalizeRoundRectRadii(radii);
|
|
368
|
+
if (rs === null)
|
|
369
|
+
return;
|
|
370
|
+
this[ZIG].roundRect(x, y, w, h, rs[0], rs[1], rs[2], rs[3]);
|
|
371
|
+
}
|
|
372
|
+
ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise = false) {
|
|
373
|
+
this[ZIG].ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise);
|
|
374
|
+
}
|
|
375
|
+
addPath(other, transform) {
|
|
376
|
+
if (transform !== undefined) {
|
|
377
|
+
this[ZIG].addPathTransform(other[ZIG], transform[ZIG]);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
this[ZIG].addPath(other[ZIG]);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// =============================================================================
|
|
385
|
+
// HTML5: CanvasGradient
|
|
386
|
+
// =============================================================================
|
|
387
|
+
//
|
|
388
|
+
// No public constructor — instances come from
|
|
389
|
+
// `ctx.createLinearGradient` / `ctx.createRadialGradient`.
|
|
390
|
+
export class CanvasGradient {
|
|
391
|
+
/** @internal */ [ZIG];
|
|
392
|
+
/** @internal — module-internal construction only. */
|
|
393
|
+
constructor(zig) {
|
|
394
|
+
this[ZIG] = zig;
|
|
395
|
+
gradientRegistry.register(this, zig, this);
|
|
396
|
+
}
|
|
397
|
+
addColorStop(offset, color) {
|
|
398
|
+
this[ZIG].addColorStop(offset, color);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// =============================================================================
|
|
402
|
+
// HTML5: CanvasPattern
|
|
403
|
+
// =============================================================================
|
|
404
|
+
//
|
|
405
|
+
// No public constructor — instances come from `ctx.createPattern(image, rep)`.
|
|
406
|
+
// Snapshots the source image at construction time, so subsequent mutations to
|
|
407
|
+
// the source ImageData/Canvas don't affect the pattern (matches HTML5 spec).
|
|
408
|
+
const REPETITION_TO_ENUM = {
|
|
409
|
+
'repeat': 0,
|
|
410
|
+
'repeat-x': 1,
|
|
411
|
+
'repeat-y': 2,
|
|
412
|
+
'no-repeat': 3,
|
|
413
|
+
};
|
|
414
|
+
export class CanvasPattern {
|
|
415
|
+
/** @internal */ [ZIG];
|
|
416
|
+
/** @internal — module-internal construction only. */
|
|
417
|
+
constructor(zig) {
|
|
418
|
+
this[ZIG] = zig;
|
|
419
|
+
patternRegistry.register(this, zig, this);
|
|
420
|
+
}
|
|
421
|
+
setTransform(matrix) {
|
|
422
|
+
let a = 1, b = 0, c = 0, d = 1, e = 0, f = 0;
|
|
423
|
+
if (matrix instanceof DOMMatrix) {
|
|
424
|
+
a = matrix.a;
|
|
425
|
+
b = matrix.b;
|
|
426
|
+
c = matrix.c;
|
|
427
|
+
d = matrix.d;
|
|
428
|
+
e = matrix.e;
|
|
429
|
+
f = matrix.f;
|
|
430
|
+
}
|
|
431
|
+
else if (matrix && typeof matrix === 'object') {
|
|
432
|
+
a = matrix.a ?? 1;
|
|
433
|
+
b = matrix.b ?? 0;
|
|
434
|
+
c = matrix.c ?? 0;
|
|
435
|
+
d = matrix.d ?? 1;
|
|
436
|
+
e = matrix.e ?? 0;
|
|
437
|
+
f = matrix.f ?? 0;
|
|
438
|
+
}
|
|
439
|
+
this[ZIG].setTransform(a, b, c, d, e, f);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const HTML5_TO_BLEND = {
|
|
443
|
+
'source-over': 'src_over',
|
|
444
|
+
'source-in': 'src_in',
|
|
445
|
+
'source-out': 'src_out',
|
|
446
|
+
'source-atop': 'src_atop',
|
|
447
|
+
'destination-over': 'dst_over',
|
|
448
|
+
'destination-in': 'dst_in',
|
|
449
|
+
'destination-out': 'dst_out',
|
|
450
|
+
'destination-atop': 'dst_atop',
|
|
451
|
+
'copy': 'copy',
|
|
452
|
+
'xor': 'xor',
|
|
453
|
+
'lighter': 'add',
|
|
454
|
+
'multiply': 'multiply',
|
|
455
|
+
'screen': 'screen',
|
|
456
|
+
'overlay': 'overlay',
|
|
457
|
+
'darken': 'darken',
|
|
458
|
+
'lighten': 'lighten',
|
|
459
|
+
'color-dodge': 'color_dodge',
|
|
460
|
+
'color-burn': 'color_burn',
|
|
461
|
+
'hard-light': 'hard_light',
|
|
462
|
+
'soft-light': 'soft_light',
|
|
463
|
+
'difference': 'difference',
|
|
464
|
+
'exclusion': 'exclusion',
|
|
465
|
+
'hue': 'hue',
|
|
466
|
+
'saturation': 'saturation',
|
|
467
|
+
'color': 'color',
|
|
468
|
+
'luminosity': 'luminosity',
|
|
469
|
+
};
|
|
470
|
+
const BLEND_TO_HTML5 = {
|
|
471
|
+
src_over: 'source-over',
|
|
472
|
+
src_in: 'source-in',
|
|
473
|
+
src_out: 'source-out',
|
|
474
|
+
src_atop: 'source-atop',
|
|
475
|
+
dst_over: 'destination-over',
|
|
476
|
+
dst_in: 'destination-in',
|
|
477
|
+
dst_out: 'destination-out',
|
|
478
|
+
dst_atop: 'destination-atop',
|
|
479
|
+
copy: 'copy',
|
|
480
|
+
xor: 'xor',
|
|
481
|
+
add: 'lighter',
|
|
482
|
+
multiply: 'multiply',
|
|
483
|
+
screen: 'screen',
|
|
484
|
+
overlay: 'overlay',
|
|
485
|
+
darken: 'darken',
|
|
486
|
+
lighten: 'lighten',
|
|
487
|
+
color_dodge: 'color-dodge',
|
|
488
|
+
color_burn: 'color-burn',
|
|
489
|
+
hard_light: 'hard-light',
|
|
490
|
+
soft_light: 'soft-light',
|
|
491
|
+
difference: 'difference',
|
|
492
|
+
exclusion: 'exclusion',
|
|
493
|
+
hue: 'hue',
|
|
494
|
+
saturation: 'saturation',
|
|
495
|
+
color: 'color',
|
|
496
|
+
luminosity: 'luminosity',
|
|
497
|
+
};
|
|
498
|
+
const HTML5_TO_LINECAP = {
|
|
499
|
+
'butt': 'butt',
|
|
500
|
+
'round': 'round',
|
|
501
|
+
'square': 'square',
|
|
502
|
+
};
|
|
503
|
+
const HTML5_TO_LINEJOIN = {
|
|
504
|
+
'miter': 'miter',
|
|
505
|
+
'bevel': 'bevel',
|
|
506
|
+
'round': 'round',
|
|
507
|
+
};
|
|
508
|
+
const HTML5_TO_FILLRULE = {
|
|
509
|
+
'nonzero': 'nonzero',
|
|
510
|
+
'evenodd': 'evenodd',
|
|
511
|
+
};
|
|
512
|
+
function bytesToBase64(bytes) {
|
|
513
|
+
const buf = Buffer.allocUnsafe(bytes.length);
|
|
514
|
+
for (let i = 0; i < bytes.length; i++)
|
|
515
|
+
buf[i] = bytes[i];
|
|
516
|
+
return buf.toString('base64');
|
|
517
|
+
}
|
|
518
|
+
// =============================================================================
|
|
519
|
+
// Fonts: registry, CSS-shorthand parser, TextMetrics
|
|
520
|
+
// =============================================================================
|
|
521
|
+
//
|
|
522
|
+
// Architecture:
|
|
523
|
+
// - `fontRegistry` : Map<familyKey, Uint8Array> — TTF bytes per family
|
|
524
|
+
// - `fontInstances` : Map<"family|sizePx", ZigFont> — cached SmFont proxies
|
|
525
|
+
// - On `ctx.font = '...'`:
|
|
526
|
+
// parseCssFont → resolve family list → look up bytes → get/create
|
|
527
|
+
// SmFont at the requested pixel size → cache the result
|
|
528
|
+
//
|
|
529
|
+
// The default Inter font (embedded in WASM via @embedFile) is registered
|
|
530
|
+
// at module load against the four CSS generic families so out-of-the-box
|
|
531
|
+
// `'10px sans-serif'` works without `registerFont(...)`.
|
|
532
|
+
function zigBytesToU8(b) {
|
|
533
|
+
const dv = b.dataView;
|
|
534
|
+
// Defensive copy — under WASM, the source buffer is the module's linear
|
|
535
|
+
// memory, which gets detached when Zig allocations grow it. Anything we
|
|
536
|
+
// need to survive past this call has to be in a JS-owned ArrayBuffer.
|
|
537
|
+
const out = new Uint8Array(dv.byteLength);
|
|
538
|
+
out.set(new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength));
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
const fontRegistry = new Map();
|
|
542
|
+
const fontInstances = new Map();
|
|
543
|
+
// Read a SFNT table directory entry by 4-char tag. Returns null if the
|
|
544
|
+
// bytes don't look like a single TrueType / OpenType font, or the tag is
|
|
545
|
+
// absent. Doesn't handle TTC collections (would need a 'ttcf' header path).
|
|
546
|
+
function readSfntTable(bytes, tag) {
|
|
547
|
+
if (bytes.length < 12)
|
|
548
|
+
return null;
|
|
549
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
550
|
+
const numTables = dv.getUint16(4);
|
|
551
|
+
const t0 = tag.charCodeAt(0), t1 = tag.charCodeAt(1), t2 = tag.charCodeAt(2), t3 = tag.charCodeAt(3);
|
|
552
|
+
for (let i = 0; i < numTables; i++) {
|
|
553
|
+
const recOff = 12 + i * 16;
|
|
554
|
+
if (recOff + 16 > bytes.length)
|
|
555
|
+
return null;
|
|
556
|
+
if (bytes[recOff] === t0 && bytes[recOff + 1] === t1 &&
|
|
557
|
+
bytes[recOff + 2] === t2 && bytes[recOff + 3] === t3) {
|
|
558
|
+
return {
|
|
559
|
+
offset: dv.getUint32(recOff + 8),
|
|
560
|
+
length: dv.getUint32(recOff + 12),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
// Auto-detect (weight, style) from OS/2.usWeightClass and head.macStyle.
|
|
567
|
+
// Both default to 400 / 'normal' when the tables are missing or malformed.
|
|
568
|
+
function detectFaceMetadata(bytes) {
|
|
569
|
+
let weight = 400;
|
|
570
|
+
let style = 'normal';
|
|
571
|
+
try {
|
|
572
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
573
|
+
const os2 = readSfntTable(bytes, 'OS/2');
|
|
574
|
+
if (os2 && os2.offset + 6 <= bytes.length) {
|
|
575
|
+
const w = dv.getUint16(os2.offset + 4);
|
|
576
|
+
if (w >= 1 && w <= 1000)
|
|
577
|
+
weight = w;
|
|
578
|
+
}
|
|
579
|
+
const head = readSfntTable(bytes, 'head');
|
|
580
|
+
if (head && head.offset + 46 <= bytes.length) {
|
|
581
|
+
const macStyle = dv.getUint16(head.offset + 44);
|
|
582
|
+
if ((macStyle & 0x2) !== 0)
|
|
583
|
+
style = 'italic';
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Malformed — keep defaults.
|
|
588
|
+
}
|
|
589
|
+
return { weight, style };
|
|
590
|
+
}
|
|
591
|
+
const STYLE_FALLBACK = {
|
|
592
|
+
italic: ['italic', 'oblique', 'normal'],
|
|
593
|
+
oblique: ['oblique', 'italic', 'normal'],
|
|
594
|
+
normal: ['normal', 'oblique', 'italic'],
|
|
595
|
+
};
|
|
596
|
+
// CSS Fonts Module 3 §5.2 weight-distance algorithm. Returns a sort key
|
|
597
|
+
// where lower = better. First component is the tier (0 = ideal range,
|
|
598
|
+
// 1 = first fallback, 2 = second fallback); second is the |delta| within
|
|
599
|
+
// that tier.
|
|
600
|
+
function weightDistanceKey(target, faceWeight) {
|
|
601
|
+
if (target >= 400 && target <= 500) {
|
|
602
|
+
if (faceWeight >= target && faceWeight <= 500)
|
|
603
|
+
return [0, faceWeight - target];
|
|
604
|
+
if (faceWeight < target)
|
|
605
|
+
return [1, target - faceWeight];
|
|
606
|
+
return [2, faceWeight - 500];
|
|
607
|
+
}
|
|
608
|
+
if (target < 400) {
|
|
609
|
+
if (faceWeight <= target)
|
|
610
|
+
return [0, target - faceWeight];
|
|
611
|
+
return [1, faceWeight - target];
|
|
612
|
+
}
|
|
613
|
+
if (faceWeight >= target)
|
|
614
|
+
return [0, faceWeight - target];
|
|
615
|
+
return [1, target - faceWeight];
|
|
616
|
+
}
|
|
617
|
+
// Pick the best face for (targetWeight, targetStyle) from a registered
|
|
618
|
+
// family. Returns the matched bytes and faux-styling flags — `fauxBold`
|
|
619
|
+
// flips on whenever the target is ≥600 but the matched face's weight is
|
|
620
|
+
// <600; `fauxItalic` flips on when the target wants italic/oblique but
|
|
621
|
+
// only normal-style faces are registered.
|
|
622
|
+
function pickFace(faces, targetWeight, targetStyle) {
|
|
623
|
+
if (faces.length === 0)
|
|
624
|
+
return null;
|
|
625
|
+
let candidates = [];
|
|
626
|
+
let chosenStyle = 'normal';
|
|
627
|
+
for (const s of STYLE_FALLBACK[targetStyle]) {
|
|
628
|
+
candidates = faces.filter(f => f.style === s);
|
|
629
|
+
if (candidates.length > 0) {
|
|
630
|
+
chosenStyle = s;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (candidates.length === 0)
|
|
635
|
+
return null;
|
|
636
|
+
let best = candidates[0];
|
|
637
|
+
let bestKey = weightDistanceKey(targetWeight, best.weight);
|
|
638
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
639
|
+
const f = candidates[i];
|
|
640
|
+
const key = weightDistanceKey(targetWeight, f.weight);
|
|
641
|
+
if (key[0] < bestKey[0] || (key[0] === bestKey[0] && key[1] < bestKey[1])) {
|
|
642
|
+
best = f;
|
|
643
|
+
bestKey = key;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const fauxBold = targetWeight >= 600 && best.weight < 600;
|
|
647
|
+
const fauxItalic = (targetStyle === 'italic' || targetStyle === 'oblique') && chosenStyle === 'normal';
|
|
648
|
+
return {
|
|
649
|
+
bytes: best.bytes,
|
|
650
|
+
weight: best.weight,
|
|
651
|
+
style: chosenStyle,
|
|
652
|
+
fauxBold,
|
|
653
|
+
fauxItalic,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
// Insert a face into a family's face list, replacing any prior face with
|
|
657
|
+
// the same (weight, style) coordinates so the latest call wins.
|
|
658
|
+
function upsertFace(family, face) {
|
|
659
|
+
const key = family.toLowerCase();
|
|
660
|
+
let faces = fontRegistry.get(key);
|
|
661
|
+
if (!faces) {
|
|
662
|
+
faces = [];
|
|
663
|
+
fontRegistry.set(key, faces);
|
|
664
|
+
}
|
|
665
|
+
const idx = faces.findIndex(f => f.weight === face.weight && f.style === face.style);
|
|
666
|
+
if (idx >= 0)
|
|
667
|
+
faces[idx] = face;
|
|
668
|
+
else
|
|
669
|
+
faces.push(face);
|
|
670
|
+
// Drop any cached SmFonts for this family — selection may now resolve
|
|
671
|
+
// differently (a newly-registered Bold can win over a faux-bold cache).
|
|
672
|
+
const prefix = `${key}|`;
|
|
673
|
+
for (const k of [...fontInstances.keys()]) {
|
|
674
|
+
if (k.startsWith(prefix))
|
|
675
|
+
fontInstances.delete(k);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// Lazy: `defaultFontBytes()` reaches into the WASM module, so it can't run
|
|
679
|
+
// until `init()` has resolved (Workers/browsers). First access seeds the
|
|
680
|
+
// registry too, so user-facing lookups for `sans-serif` etc. just work.
|
|
681
|
+
const DEFAULT_FAMILIES = ['sans-serif', 'serif', 'monospace', 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace'];
|
|
682
|
+
let defaultFamilyBytesCache = null;
|
|
683
|
+
function getDefaultFamilyBytes() {
|
|
684
|
+
if (defaultFamilyBytesCache)
|
|
685
|
+
return defaultFamilyBytesCache;
|
|
686
|
+
defaultFamilyBytesCache = zigBytesToU8(defaultFontBytes());
|
|
687
|
+
// Embedded Manrope is variable but pinned at the default instance
|
|
688
|
+
// (Regular). Register it as a single 400/normal face under each generic
|
|
689
|
+
// family — face matching will faux-bold / faux-italic when the lookup
|
|
690
|
+
// asks for something different.
|
|
691
|
+
const defaultFace = {
|
|
692
|
+
weight: 400,
|
|
693
|
+
style: 'normal',
|
|
694
|
+
bytes: defaultFamilyBytesCache,
|
|
695
|
+
};
|
|
696
|
+
for (const fam of DEFAULT_FAMILIES) {
|
|
697
|
+
if (!fontRegistry.has(fam))
|
|
698
|
+
fontRegistry.set(fam, [defaultFace]);
|
|
699
|
+
}
|
|
700
|
+
return defaultFamilyBytesCache;
|
|
701
|
+
}
|
|
702
|
+
export function registerFont(bytes, family, descriptor) {
|
|
703
|
+
if (typeof family !== 'string' || family.length === 0) {
|
|
704
|
+
throw new TypeError('registerFont: family must be a non-empty string');
|
|
705
|
+
}
|
|
706
|
+
let view;
|
|
707
|
+
if (bytes instanceof Uint8Array) {
|
|
708
|
+
view = bytes;
|
|
709
|
+
}
|
|
710
|
+
else if (ArrayBuffer.isView(bytes)) {
|
|
711
|
+
view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
712
|
+
}
|
|
713
|
+
else if (bytes instanceof ArrayBuffer) {
|
|
714
|
+
view = new Uint8Array(bytes);
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
throw new TypeError('registerFont: bytes must be an ArrayBuffer or ArrayBufferView');
|
|
718
|
+
}
|
|
719
|
+
const detected = detectFaceMetadata(view);
|
|
720
|
+
const weight = resolveWeightDescriptor(descriptor?.weight, detected.weight);
|
|
721
|
+
const style = descriptor?.style ?? detected.style;
|
|
722
|
+
upsertFace(family, { weight, style, bytes: view });
|
|
723
|
+
}
|
|
724
|
+
function resolveWeightDescriptor(input, fallback) {
|
|
725
|
+
if (input === undefined)
|
|
726
|
+
return fallback;
|
|
727
|
+
if (typeof input === 'number' && Number.isFinite(input) && input >= 1 && input <= 1000) {
|
|
728
|
+
return Math.round(input);
|
|
729
|
+
}
|
|
730
|
+
if (typeof input === 'string') {
|
|
731
|
+
const lc = input.toLowerCase();
|
|
732
|
+
const kw = WEIGHT_KEYWORDS[lc];
|
|
733
|
+
if (kw !== undefined)
|
|
734
|
+
return kw;
|
|
735
|
+
const num = Number(lc);
|
|
736
|
+
if (Number.isFinite(num) && num >= 1 && num <= 1000)
|
|
737
|
+
return Math.round(num);
|
|
738
|
+
}
|
|
739
|
+
return fallback;
|
|
740
|
+
}
|
|
741
|
+
// CSS Fonts Module 3 keyword sets. `normal` is intentionally absent from
|
|
742
|
+
// the style keyword set because it's the default — accepting it as a style
|
|
743
|
+
// token would shadow its valid uses as a font-variant / font-stretch /
|
|
744
|
+
// font-weight value (CSS lets it appear in any of those slots and the
|
|
745
|
+
// shorthand has to disambiguate by position; we just leave defaults alone
|
|
746
|
+
// when we see it).
|
|
747
|
+
const STYLE_KEYWORDS = new Set(['italic', 'oblique']);
|
|
748
|
+
const WEIGHT_KEYWORDS = {
|
|
749
|
+
normal: 400,
|
|
750
|
+
bold: 700,
|
|
751
|
+
// bolder / lighter are computed-against-parent in CSS; outside a DOM
|
|
752
|
+
// there's no parent, so we collapse to the most common practical mapping.
|
|
753
|
+
bolder: 700,
|
|
754
|
+
lighter: 300,
|
|
755
|
+
};
|
|
756
|
+
const VARIANT_KEYWORDS = new Set(['normal', 'small-caps']);
|
|
757
|
+
const STRETCH_KEYWORDS = new Set([
|
|
758
|
+
'normal', 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed',
|
|
759
|
+
'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded',
|
|
760
|
+
]);
|
|
761
|
+
// CSS font-shorthand parser. Recognises optional leading style / variant /
|
|
762
|
+
// weight / stretch tokens, the required `<size>px` (with optional
|
|
763
|
+
// `/<lineheight>` ignored), and a comma-separated family list.
|
|
764
|
+
// '12px sans-serif' → 400, normal, 12, ['sans-serif']
|
|
765
|
+
// 'bold 14.5px Arial' → 700, normal, 14.5, ['arial']
|
|
766
|
+
// 'italic 700 16px "Helv", sans' → 700, italic, 16, ['helv', 'sans']
|
|
767
|
+
// '300 italic 24px/1.5 Inter' → 300, italic, 24, ['inter']
|
|
768
|
+
// Returns null if the size+family core can't be located; callers keep the
|
|
769
|
+
// previous font (HTML5 spec: invalid font assignments are silently ignored).
|
|
770
|
+
function parseCssFont(input) {
|
|
771
|
+
if (typeof input !== 'string')
|
|
772
|
+
return null;
|
|
773
|
+
const trimmed = input.trim();
|
|
774
|
+
if (!trimmed)
|
|
775
|
+
return null;
|
|
776
|
+
// Locate the size token (number followed by 'px', optionally followed by
|
|
777
|
+
// `/<line-height>`). Anchored to a word boundary so '12pxxx' won't match.
|
|
778
|
+
const sizeRe = /(?:^|\s)(\d+(?:\.\d+)?)\s*px(?:\s*\/\s*\S+)?(?=\s|$)/;
|
|
779
|
+
const sizeMatch = sizeRe.exec(trimmed);
|
|
780
|
+
if (!sizeMatch)
|
|
781
|
+
return null;
|
|
782
|
+
const sizePx = parseFloat(sizeMatch[1]);
|
|
783
|
+
if (!isFinite(sizePx) || sizePx <= 0)
|
|
784
|
+
return null;
|
|
785
|
+
const sizeStart = sizeMatch.index + (sizeMatch[0].startsWith(' ') ? 1 : 0);
|
|
786
|
+
const sizeEnd = sizeMatch.index + sizeMatch[0].length;
|
|
787
|
+
const prefix = trimmed.slice(0, sizeStart).trim();
|
|
788
|
+
const familiesRaw = trimmed.slice(sizeEnd).trim();
|
|
789
|
+
let style = 'normal';
|
|
790
|
+
let weight = 400;
|
|
791
|
+
let styleSet = false;
|
|
792
|
+
let weightSet = false;
|
|
793
|
+
if (prefix) {
|
|
794
|
+
const tokens = prefix.split(/\s+/).filter(Boolean);
|
|
795
|
+
for (const t of tokens) {
|
|
796
|
+
const lc = t.toLowerCase();
|
|
797
|
+
if (!styleSet && STYLE_KEYWORDS.has(lc)) {
|
|
798
|
+
style = lc;
|
|
799
|
+
styleSet = true;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (!weightSet) {
|
|
803
|
+
if (WEIGHT_KEYWORDS[lc] !== undefined) {
|
|
804
|
+
weight = WEIGHT_KEYWORDS[lc];
|
|
805
|
+
weightSet = true;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const num = Number(lc);
|
|
809
|
+
if (Number.isFinite(num) && Number.isInteger(num) && num >= 1 && num <= 1000) {
|
|
810
|
+
weight = num;
|
|
811
|
+
weightSet = true;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// Accept-and-ignore: variant / stretch / unknown tokens. Spec-strict
|
|
816
|
+
// parsers reject unknowns, but in practice libraries pass odd things
|
|
817
|
+
// and silently dropping them is friendlier than rejecting the whole
|
|
818
|
+
// shorthand.
|
|
819
|
+
if (VARIANT_KEYWORDS.has(lc) || STRETCH_KEYWORDS.has(lc))
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!familiesRaw)
|
|
824
|
+
return null;
|
|
825
|
+
const families = familiesRaw
|
|
826
|
+
.split(',')
|
|
827
|
+
.map(s => s.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
|
828
|
+
.filter(Boolean);
|
|
829
|
+
if (families.length === 0)
|
|
830
|
+
return null;
|
|
831
|
+
// Canonicalize per HTML5: `[<style>] [<weight>] <size>px <family-list>`.
|
|
832
|
+
// Style/weight emitted only when non-default, matching what browsers do.
|
|
833
|
+
const parts = [];
|
|
834
|
+
if (style !== 'normal')
|
|
835
|
+
parts.push(style);
|
|
836
|
+
if (weight !== 400)
|
|
837
|
+
parts.push(String(weight));
|
|
838
|
+
parts.push(`${sizePx}px`);
|
|
839
|
+
parts.push(families.join(', '));
|
|
840
|
+
const canonical = parts.join(' ');
|
|
841
|
+
return { sizePx, families, weight, style, canonical };
|
|
842
|
+
}
|
|
843
|
+
function buildSmFont(bytes, sizePx, fauxBold, fauxItalic) {
|
|
844
|
+
const inst = SmFont.fromBytes(bytes, sizePx);
|
|
845
|
+
if (fauxBold || fauxItalic)
|
|
846
|
+
inst.setSynth(fauxBold, fauxItalic);
|
|
847
|
+
return inst;
|
|
848
|
+
}
|
|
849
|
+
function getFontInstance(parsed) {
|
|
850
|
+
// Seed default-family bytes on first use (and after `init()` has resolved).
|
|
851
|
+
getDefaultFamilyBytes();
|
|
852
|
+
for (const family of parsed.families) {
|
|
853
|
+
const faces = fontRegistry.get(family);
|
|
854
|
+
if (!faces)
|
|
855
|
+
continue;
|
|
856
|
+
const match = pickFace(faces, parsed.weight, parsed.style);
|
|
857
|
+
if (!match)
|
|
858
|
+
continue;
|
|
859
|
+
const key = `${family}|${parsed.sizePx}|${parsed.weight}|${parsed.style}`;
|
|
860
|
+
let inst = fontInstances.get(key);
|
|
861
|
+
if (!inst) {
|
|
862
|
+
inst = buildSmFont(match.bytes, parsed.sizePx, match.fauxBold, match.fauxItalic);
|
|
863
|
+
fontInstances.set(key, inst);
|
|
864
|
+
}
|
|
865
|
+
return inst;
|
|
866
|
+
}
|
|
867
|
+
// No registered family matched; fall through to the default — sans-serif
|
|
868
|
+
// is always seeded by `getDefaultFamilyBytes()`. Re-run face matching
|
|
869
|
+
// against it so faux flags are populated for the fallback path too.
|
|
870
|
+
const fallbackFaces = fontRegistry.get('sans-serif');
|
|
871
|
+
const fbMatch = pickFace(fallbackFaces, parsed.weight, parsed.style);
|
|
872
|
+
const fallbackKey = `sans-serif|${parsed.sizePx}|${parsed.weight}|${parsed.style}`;
|
|
873
|
+
let inst = fontInstances.get(fallbackKey);
|
|
874
|
+
if (!inst) {
|
|
875
|
+
inst = buildSmFont(fbMatch.bytes, parsed.sizePx, fbMatch.fauxBold, fbMatch.fauxItalic);
|
|
876
|
+
fontInstances.set(fallbackKey, inst);
|
|
877
|
+
}
|
|
878
|
+
return inst;
|
|
879
|
+
}
|
|
880
|
+
function parseCssFilter(input) {
|
|
881
|
+
if (typeof input !== 'string')
|
|
882
|
+
return null;
|
|
883
|
+
const trimmed = input.trim();
|
|
884
|
+
if (trimmed === '' || trimmed === 'none')
|
|
885
|
+
return { verbs: [], params: [] };
|
|
886
|
+
const verbs = [];
|
|
887
|
+
const params = [];
|
|
888
|
+
// Match `funcname(args)` segments. Allow nested commas within args.
|
|
889
|
+
const re = /([a-z-]+)\(([^)]*)\)/gi;
|
|
890
|
+
let m;
|
|
891
|
+
let consumed = 0;
|
|
892
|
+
while ((m = re.exec(trimmed)) !== null) {
|
|
893
|
+
consumed = m.index + m[0].length;
|
|
894
|
+
const fn = m[1].toLowerCase();
|
|
895
|
+
const arg = m[2].trim();
|
|
896
|
+
switch (fn) {
|
|
897
|
+
case 'blur': {
|
|
898
|
+
const px = parseCssLengthPx(arg);
|
|
899
|
+
if (px === null)
|
|
900
|
+
return null;
|
|
901
|
+
// sigma = blur / 2 (matches Chromium/Skia interpretation).
|
|
902
|
+
verbs.push(0);
|
|
903
|
+
params.push(px / 2);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
case 'brightness': {
|
|
907
|
+
const f = parseCssPercentOrNumber(arg);
|
|
908
|
+
if (f === null)
|
|
909
|
+
return null;
|
|
910
|
+
verbs.push(1);
|
|
911
|
+
params.push(f);
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case 'contrast': {
|
|
915
|
+
const f = parseCssPercentOrNumber(arg);
|
|
916
|
+
if (f === null)
|
|
917
|
+
return null;
|
|
918
|
+
verbs.push(2);
|
|
919
|
+
params.push(f);
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
default:
|
|
923
|
+
// Unknown filter function — recognized but no-op at render time.
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// Anything left over after the last match means we couldn't parse it.
|
|
928
|
+
if (trimmed.slice(consumed).trim() !== '')
|
|
929
|
+
return null;
|
|
930
|
+
return { verbs, params };
|
|
931
|
+
}
|
|
932
|
+
function parseCssPercentOrNumber(s) {
|
|
933
|
+
if (typeof s !== 'string')
|
|
934
|
+
return null;
|
|
935
|
+
const t = s.trim();
|
|
936
|
+
if (t.endsWith('%')) {
|
|
937
|
+
const v = parseFloat(t.slice(0, -1));
|
|
938
|
+
if (!isFinite(v))
|
|
939
|
+
return null;
|
|
940
|
+
return v / 100;
|
|
941
|
+
}
|
|
942
|
+
const v = parseFloat(t);
|
|
943
|
+
if (!isFinite(v))
|
|
944
|
+
return null;
|
|
945
|
+
return v;
|
|
946
|
+
}
|
|
947
|
+
function normalizeRoundRectRadii(radii) {
|
|
948
|
+
const toScalar = (v) => {
|
|
949
|
+
if (v === undefined)
|
|
950
|
+
return 0;
|
|
951
|
+
if (typeof v === 'number') {
|
|
952
|
+
if (!isFinite(v))
|
|
953
|
+
return null;
|
|
954
|
+
if (v < 0)
|
|
955
|
+
throw new RangeError('roundRect: negative radius');
|
|
956
|
+
return v;
|
|
957
|
+
}
|
|
958
|
+
if (v && typeof v === 'object') {
|
|
959
|
+
const x = v.x;
|
|
960
|
+
if (x === undefined)
|
|
961
|
+
return 0;
|
|
962
|
+
if (typeof x !== 'number' || !isFinite(x))
|
|
963
|
+
return null;
|
|
964
|
+
if (x < 0)
|
|
965
|
+
throw new RangeError('roundRect: negative radius');
|
|
966
|
+
return x;
|
|
967
|
+
}
|
|
968
|
+
return null;
|
|
969
|
+
};
|
|
970
|
+
if (radii === undefined)
|
|
971
|
+
return [0, 0, 0, 0];
|
|
972
|
+
if (typeof radii === 'number' || (radii && typeof radii === 'object' && !Array.isArray(radii))) {
|
|
973
|
+
const r = toScalar(radii);
|
|
974
|
+
if (r === null)
|
|
975
|
+
return null;
|
|
976
|
+
return [r, r, r, r];
|
|
977
|
+
}
|
|
978
|
+
if (Array.isArray(radii)) {
|
|
979
|
+
if (radii.length === 0 || radii.length > 4) {
|
|
980
|
+
throw new RangeError('roundRect: radii array must have length 1..4');
|
|
981
|
+
}
|
|
982
|
+
const r0 = toScalar(radii[0]);
|
|
983
|
+
if (r0 === null)
|
|
984
|
+
return null;
|
|
985
|
+
if (radii.length === 1)
|
|
986
|
+
return [r0, r0, r0, r0];
|
|
987
|
+
const r1 = toScalar(radii[1]);
|
|
988
|
+
if (r1 === null)
|
|
989
|
+
return null;
|
|
990
|
+
if (radii.length === 2)
|
|
991
|
+
return [r0, r1, r0, r1];
|
|
992
|
+
const r2 = toScalar(radii[2]);
|
|
993
|
+
if (r2 === null)
|
|
994
|
+
return null;
|
|
995
|
+
if (radii.length === 3)
|
|
996
|
+
return [r0, r1, r2, r1];
|
|
997
|
+
const r3 = toScalar(radii[3]);
|
|
998
|
+
if (r3 === null)
|
|
999
|
+
return null;
|
|
1000
|
+
return [r0, r1, r2, r3];
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
const TEXT_ALIGNS = new Set(['start', 'end', 'left', 'right', 'center']);
|
|
1005
|
+
const TEXT_BASELINES = new Set(['top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom']);
|
|
1006
|
+
const DIRECTIONS = new Set(['ltr', 'rtl', 'inherit']);
|
|
1007
|
+
const FONT_KERNINGS = new Set(['auto', 'normal', 'none']);
|
|
1008
|
+
const IMAGE_SMOOTHING_QUALITIES = new Set(['low', 'medium', 'high']);
|
|
1009
|
+
// Subset CSS-length parser for letterSpacing/wordSpacing. HTML5/CSS allows
|
|
1010
|
+
// many length units (`em`, `rem`, `pt`, ...) but in canvas they all collapse
|
|
1011
|
+
// to pixel-space at parse time. We support `px` directly. Other units cause
|
|
1012
|
+
// the assignment to be silently ignored per the HTML5 invalidates-other rule.
|
|
1013
|
+
function parseCssLengthPx(s) {
|
|
1014
|
+
if (typeof s !== 'string')
|
|
1015
|
+
return null;
|
|
1016
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*px\s*$/i.exec(s);
|
|
1017
|
+
if (!m)
|
|
1018
|
+
return null;
|
|
1019
|
+
const v = parseFloat(m[1]);
|
|
1020
|
+
if (!isFinite(v))
|
|
1021
|
+
return null;
|
|
1022
|
+
return v;
|
|
1023
|
+
}
|
|
1024
|
+
function canonicalLengthPx(v) {
|
|
1025
|
+
return `${v}px`;
|
|
1026
|
+
}
|
|
1027
|
+
export class TextMetrics {
|
|
1028
|
+
width;
|
|
1029
|
+
/** @internal */ constructor(width) {
|
|
1030
|
+
this.width = width;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function rgbaU32ToCssString(rgba) {
|
|
1034
|
+
const r = rgba & 0xff;
|
|
1035
|
+
const g = (rgba >>> 8) & 0xff;
|
|
1036
|
+
const b = (rgba >>> 16) & 0xff;
|
|
1037
|
+
const a = (rgba >>> 24) & 0xff;
|
|
1038
|
+
if (a === 0xff) {
|
|
1039
|
+
// Canonical hex form for fully opaque.
|
|
1040
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
1041
|
+
}
|
|
1042
|
+
return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(4).replace(/\.?0+$/, '') || '0'})`;
|
|
1043
|
+
}
|
|
1044
|
+
export class CanvasRenderingContext2D {
|
|
1045
|
+
/** @internal */ [ZIG];
|
|
1046
|
+
// Cached canonical strings so getter round-trips return what was set.
|
|
1047
|
+
#fillStyleStr = '#000000';
|
|
1048
|
+
#strokeStyleStr = '#000000';
|
|
1049
|
+
// When fillStyle/strokeStyle are set to a CanvasGradient or CanvasPattern,
|
|
1050
|
+
// hold the wrapper here. The Zig side stores a *const SmGradient/SmPattern
|
|
1051
|
+
// pointer; this reference keeps that handle alive (and reachable through
|
|
1052
|
+
// the FinalizationRegistry) for as long as it's the active style.
|
|
1053
|
+
#fillStyleObj = null;
|
|
1054
|
+
#strokeStyleObj = null;
|
|
1055
|
+
// ---- Text state -------------------------------------------------------
|
|
1056
|
+
// HTML5 default font is `'10px sans-serif'`.
|
|
1057
|
+
#fontStr = '10px sans-serif';
|
|
1058
|
+
// Lazy: resolving a font reaches into WASM, which isn't available until
|
|
1059
|
+
// `init()` resolves. First access (after init) materializes it.
|
|
1060
|
+
#fontInstance = null;
|
|
1061
|
+
#getFontInstance() {
|
|
1062
|
+
return this.#fontInstance ??= getFontInstance(parseCssFont(this.#fontStr));
|
|
1063
|
+
}
|
|
1064
|
+
#textAlign = 'start';
|
|
1065
|
+
#textBaseline = 'alphabetic';
|
|
1066
|
+
#direction = 'inherit';
|
|
1067
|
+
#letterSpacing = '0px';
|
|
1068
|
+
#letterSpacingPx = 0;
|
|
1069
|
+
#wordSpacing = '0px';
|
|
1070
|
+
#wordSpacingPx = 0;
|
|
1071
|
+
#fontKerning = 'auto';
|
|
1072
|
+
// No font-variant infrastructure yet; stored verbatim, no rendering effect.
|
|
1073
|
+
#fontStretch = 'normal';
|
|
1074
|
+
#fontVariantCaps = 'normal';
|
|
1075
|
+
// stb_truetype offers no hinting toggle; stored verbatim, no rendering effect.
|
|
1076
|
+
#textRendering = 'auto';
|
|
1077
|
+
// Filter chain rendering lands in phase 7; phase 1 stores verbatim.
|
|
1078
|
+
#filter = 'none';
|
|
1079
|
+
// Image smoothing storage; phase 4 wires the bilinear branch.
|
|
1080
|
+
#imageSmoothingEnabled = true;
|
|
1081
|
+
#imageSmoothingQuality = 'low';
|
|
1082
|
+
#shadowBlur = 0;
|
|
1083
|
+
#shadowColorStr = 'rgba(0, 0, 0, 0)';
|
|
1084
|
+
#shadowOffsetX = 0;
|
|
1085
|
+
#shadowOffsetY = 0;
|
|
1086
|
+
#canvas;
|
|
1087
|
+
/** @internal — only `Canvas.getContext('2d')` constructs these. */
|
|
1088
|
+
constructor(canvas, zig) {
|
|
1089
|
+
this[ZIG] = zig;
|
|
1090
|
+
this.#canvas = canvas;
|
|
1091
|
+
}
|
|
1092
|
+
get canvas() { return this.#canvas; }
|
|
1093
|
+
getContextAttributes() {
|
|
1094
|
+
return {
|
|
1095
|
+
alpha: true,
|
|
1096
|
+
colorSpace: 'srgb',
|
|
1097
|
+
desynchronized: false,
|
|
1098
|
+
willReadFrequently: false,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Drop the JS-side references this context holds. The underlying SmCanvas
|
|
1103
|
+
* is owned by the SmSurface and is deinit'd when `Canvas#destroy()` runs;
|
|
1104
|
+
* this method is a no-op for Zig memory but releases JS-held strong refs
|
|
1105
|
+
* to gradient / pattern style objects so they GC sooner. Idempotent.
|
|
1106
|
+
*/
|
|
1107
|
+
destroy() {
|
|
1108
|
+
this.#fillStyleObj = null;
|
|
1109
|
+
this.#strokeStyleObj = null;
|
|
1110
|
+
this.#fontInstance = null;
|
|
1111
|
+
}
|
|
1112
|
+
[Symbol.dispose]() { this.destroy(); }
|
|
1113
|
+
// ---- State ------------------------------------------------------------
|
|
1114
|
+
save() { this[ZIG].save(); }
|
|
1115
|
+
restore() { this[ZIG].restore(); }
|
|
1116
|
+
reset() {
|
|
1117
|
+
this[ZIG].reset();
|
|
1118
|
+
// Reset JS-side mirror fields (those not stored in Zig).
|
|
1119
|
+
this.#fillStyleStr = '#000000';
|
|
1120
|
+
this.#strokeStyleStr = '#000000';
|
|
1121
|
+
this.#fillStyleObj = null;
|
|
1122
|
+
this.#strokeStyleObj = null;
|
|
1123
|
+
this.#fontStr = '10px sans-serif';
|
|
1124
|
+
this.#fontInstance = getFontInstance(parseCssFont(this.#fontStr));
|
|
1125
|
+
this.#textAlign = 'start';
|
|
1126
|
+
this.#textBaseline = 'alphabetic';
|
|
1127
|
+
this.#direction = 'inherit';
|
|
1128
|
+
this.#letterSpacing = '0px';
|
|
1129
|
+
this.#letterSpacingPx = 0;
|
|
1130
|
+
this.#wordSpacing = '0px';
|
|
1131
|
+
this.#wordSpacingPx = 0;
|
|
1132
|
+
this.#fontKerning = 'auto';
|
|
1133
|
+
this.#fontStretch = 'normal';
|
|
1134
|
+
this.#fontVariantCaps = 'normal';
|
|
1135
|
+
this.#textRendering = 'auto';
|
|
1136
|
+
this.#filter = 'none';
|
|
1137
|
+
this.#imageSmoothingEnabled = true;
|
|
1138
|
+
this.#imageSmoothingQuality = 'low';
|
|
1139
|
+
this.#shadowBlur = 0;
|
|
1140
|
+
this.#shadowColorStr = 'rgba(0, 0, 0, 0)';
|
|
1141
|
+
this.#shadowOffsetX = 0;
|
|
1142
|
+
this.#shadowOffsetY = 0;
|
|
1143
|
+
// Filter chain is cleared by the Zig reset() which empties filter_verbs.
|
|
1144
|
+
// Re-send an empty chain to be defensive in case zigar buffer state lags.
|
|
1145
|
+
this[ZIG].setFilterChain(new Uint8Array(0), new Float64Array(0));
|
|
1146
|
+
}
|
|
1147
|
+
// ---- Transforms -------------------------------------------------------
|
|
1148
|
+
translate(tx, ty) { this[ZIG].translate(tx, ty); }
|
|
1149
|
+
rotate(angleRadians) { this[ZIG].rotate(angleRadians); }
|
|
1150
|
+
scale(sx, sy) { this[ZIG].scale(sx, sy); }
|
|
1151
|
+
transform(a, b, c, d, e, f) {
|
|
1152
|
+
this[ZIG].transform(a, b, c, d, e, f);
|
|
1153
|
+
}
|
|
1154
|
+
setTransform(a, b, c, d, e, f) {
|
|
1155
|
+
this[ZIG].setTransform(a, b, c, d, e, f);
|
|
1156
|
+
}
|
|
1157
|
+
resetTransform() { this[ZIG].resetTransform(); }
|
|
1158
|
+
getTransform() {
|
|
1159
|
+
return DOMMatrix[FROM_ZIG](this[ZIG].getTransform());
|
|
1160
|
+
}
|
|
1161
|
+
// ---- Styles (CSS strings per HTML5) -----------------------------------
|
|
1162
|
+
get fillStyle() {
|
|
1163
|
+
return this.#fillStyleObj ?? this.#fillStyleStr;
|
|
1164
|
+
}
|
|
1165
|
+
set fillStyle(v) {
|
|
1166
|
+
if (v instanceof CanvasGradient) {
|
|
1167
|
+
this.#fillStyleObj = v;
|
|
1168
|
+
this[ZIG].setFillGradient(v[ZIG]);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (v instanceof CanvasPattern) {
|
|
1172
|
+
this.#fillStyleObj = v;
|
|
1173
|
+
this[ZIG].setFillPattern(v[ZIG]);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (typeof v !== 'string')
|
|
1177
|
+
return;
|
|
1178
|
+
const rgba = parseCssColor(v);
|
|
1179
|
+
if (rgba === null)
|
|
1180
|
+
return; // invalid input: silently ignored per spec
|
|
1181
|
+
this.#fillStyleObj = null;
|
|
1182
|
+
this.#fillStyleStr = rgbaU32ToCssString(rgba);
|
|
1183
|
+
this[ZIG].setFillStyle(rgba & 0xff, (rgba >>> 8) & 0xff, (rgba >>> 16) & 0xff, (rgba >>> 24) & 0xff);
|
|
1184
|
+
}
|
|
1185
|
+
get strokeStyle() {
|
|
1186
|
+
return this.#strokeStyleObj ?? this.#strokeStyleStr;
|
|
1187
|
+
}
|
|
1188
|
+
set strokeStyle(v) {
|
|
1189
|
+
if (v instanceof CanvasGradient) {
|
|
1190
|
+
this.#strokeStyleObj = v;
|
|
1191
|
+
this[ZIG].setStrokeGradient(v[ZIG]);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (v instanceof CanvasPattern) {
|
|
1195
|
+
this.#strokeStyleObj = v;
|
|
1196
|
+
this[ZIG].setStrokePattern(v[ZIG]);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (typeof v !== 'string')
|
|
1200
|
+
return;
|
|
1201
|
+
const rgba = parseCssColor(v);
|
|
1202
|
+
if (rgba === null)
|
|
1203
|
+
return;
|
|
1204
|
+
this.#strokeStyleObj = null;
|
|
1205
|
+
this.#strokeStyleStr = rgbaU32ToCssString(rgba);
|
|
1206
|
+
this[ZIG].setStrokeStyle(rgba & 0xff, (rgba >>> 8) & 0xff, (rgba >>> 16) & 0xff, (rgba >>> 24) & 0xff);
|
|
1207
|
+
}
|
|
1208
|
+
get lineWidth() { return this[ZIG].lineWidth; }
|
|
1209
|
+
set lineWidth(v) {
|
|
1210
|
+
if (typeof v !== 'number' || !isFinite(v) || v <= 0)
|
|
1211
|
+
return;
|
|
1212
|
+
this[ZIG].setLineWidth(v);
|
|
1213
|
+
}
|
|
1214
|
+
get lineCap() { return String(this[ZIG].lineCap); }
|
|
1215
|
+
set lineCap(v) {
|
|
1216
|
+
const mapped = HTML5_TO_LINECAP[v];
|
|
1217
|
+
if (mapped === undefined)
|
|
1218
|
+
return;
|
|
1219
|
+
this[ZIG].lineCap = mapped;
|
|
1220
|
+
}
|
|
1221
|
+
get lineJoin() { return String(this[ZIG].lineJoin); }
|
|
1222
|
+
set lineJoin(v) {
|
|
1223
|
+
const mapped = HTML5_TO_LINEJOIN[v];
|
|
1224
|
+
if (mapped === undefined)
|
|
1225
|
+
return;
|
|
1226
|
+
this[ZIG].lineJoin = mapped;
|
|
1227
|
+
}
|
|
1228
|
+
get miterLimit() { return this[ZIG].miterLimit; }
|
|
1229
|
+
set miterLimit(v) {
|
|
1230
|
+
if (typeof v !== 'number' || !isFinite(v) || v <= 0)
|
|
1231
|
+
return;
|
|
1232
|
+
this[ZIG].setMiterLimit(v);
|
|
1233
|
+
}
|
|
1234
|
+
get lineDashOffset() { return this[ZIG].lineDashOffset; }
|
|
1235
|
+
set lineDashOffset(v) {
|
|
1236
|
+
if (typeof v !== 'number' || !isFinite(v))
|
|
1237
|
+
return;
|
|
1238
|
+
this[ZIG].setLineDashOffset(v);
|
|
1239
|
+
}
|
|
1240
|
+
setLineDash(segments) {
|
|
1241
|
+
if (!Array.isArray(segments))
|
|
1242
|
+
return;
|
|
1243
|
+
// Spec: any non-finite or negative entry → invalid, ignored entirely.
|
|
1244
|
+
// The Zig setter re-validates as a defensive belt; we duplicate here so
|
|
1245
|
+
// we don't pay for a Float64Array allocation when the input is invalid.
|
|
1246
|
+
for (const s of segments) {
|
|
1247
|
+
if (typeof s !== 'number' || !isFinite(s) || s < 0)
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const buf = new Float64Array(segments);
|
|
1251
|
+
this[ZIG].setLineDash(buf);
|
|
1252
|
+
}
|
|
1253
|
+
getLineDash() {
|
|
1254
|
+
const slice = this[ZIG].getLineDash();
|
|
1255
|
+
const out = [];
|
|
1256
|
+
for (let i = 0; i < slice.length; i++)
|
|
1257
|
+
out.push(slice[i]);
|
|
1258
|
+
return out;
|
|
1259
|
+
}
|
|
1260
|
+
// ---- Compositing ------------------------------------------------------
|
|
1261
|
+
get globalAlpha() { return this[ZIG].alpha / 255; }
|
|
1262
|
+
set globalAlpha(v) {
|
|
1263
|
+
if (typeof v !== 'number' || !isFinite(v) || v < 0 || v > 1)
|
|
1264
|
+
return;
|
|
1265
|
+
this[ZIG].alpha = Math.round(v * 255);
|
|
1266
|
+
}
|
|
1267
|
+
get globalCompositeOperation() {
|
|
1268
|
+
return BLEND_TO_HTML5[String(this[ZIG].blendMode)] ?? 'source-over';
|
|
1269
|
+
}
|
|
1270
|
+
set globalCompositeOperation(v) {
|
|
1271
|
+
const mapped = HTML5_TO_BLEND[v];
|
|
1272
|
+
if (mapped === undefined)
|
|
1273
|
+
return;
|
|
1274
|
+
this[ZIG].blendMode = mapped;
|
|
1275
|
+
}
|
|
1276
|
+
// ---- Drawing rectangles ----------------------------------------------
|
|
1277
|
+
fillRect(x, y, w, h) {
|
|
1278
|
+
this[ZIG].fillRect(x, y, w, h);
|
|
1279
|
+
}
|
|
1280
|
+
strokeRect(x, y, w, h) {
|
|
1281
|
+
this[ZIG].strokeRect(x, y, w, h);
|
|
1282
|
+
}
|
|
1283
|
+
clearRect(x, y, w, h) {
|
|
1284
|
+
this[ZIG].clearRect(x, y, w, h);
|
|
1285
|
+
}
|
|
1286
|
+
// ---- Paths ------------------------------------------------------------
|
|
1287
|
+
beginPath() { this[ZIG].beginPath(); }
|
|
1288
|
+
closePath() { this[ZIG].closePath(); }
|
|
1289
|
+
moveTo(x, y) { this[ZIG].moveTo(x, y); }
|
|
1290
|
+
lineTo(x, y) { this[ZIG].lineTo(x, y); }
|
|
1291
|
+
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) {
|
|
1292
|
+
this[ZIG].bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
|
|
1293
|
+
}
|
|
1294
|
+
quadraticCurveTo(cpx, cpy, x, y) {
|
|
1295
|
+
this[ZIG].quadraticCurveTo(cpx, cpy, x, y);
|
|
1296
|
+
}
|
|
1297
|
+
rect(x, y, w, h) {
|
|
1298
|
+
this[ZIG].rect(x, y, w, h);
|
|
1299
|
+
}
|
|
1300
|
+
arc(cx, cy, r, startAngle, endAngle, counterclockwise = false) {
|
|
1301
|
+
this[ZIG].arc(cx, cy, r, startAngle, endAngle, counterclockwise);
|
|
1302
|
+
}
|
|
1303
|
+
arcTo(x1, y1, x2, y2, r) {
|
|
1304
|
+
if (typeof r === 'number' && isFinite(r) && r < 0) {
|
|
1305
|
+
throw new DOMException('arcTo: negative radius', 'IndexSizeError');
|
|
1306
|
+
}
|
|
1307
|
+
this[ZIG].arcTo(x1, y1, x2, y2, r);
|
|
1308
|
+
}
|
|
1309
|
+
roundRect(x, y, w, h, radii) {
|
|
1310
|
+
const rs = normalizeRoundRectRadii(radii);
|
|
1311
|
+
if (rs === null)
|
|
1312
|
+
return;
|
|
1313
|
+
this[ZIG].roundRect(x, y, w, h, rs[0], rs[1], rs[2], rs[3]);
|
|
1314
|
+
}
|
|
1315
|
+
ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise = false) {
|
|
1316
|
+
this[ZIG].ellipse(cx, cy, rx, ry, rotation, startAngle, endAngle, counterclockwise);
|
|
1317
|
+
}
|
|
1318
|
+
fill(pathOrRule, maybeRule) {
|
|
1319
|
+
let rule = 'nonzero';
|
|
1320
|
+
let path = null;
|
|
1321
|
+
if (pathOrRule instanceof Path2D) {
|
|
1322
|
+
path = pathOrRule;
|
|
1323
|
+
if (maybeRule !== undefined) {
|
|
1324
|
+
const mapped = HTML5_TO_FILLRULE[maybeRule];
|
|
1325
|
+
if (mapped !== undefined)
|
|
1326
|
+
rule = mapped;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else if (typeof pathOrRule === 'string') {
|
|
1330
|
+
const mapped = HTML5_TO_FILLRULE[pathOrRule];
|
|
1331
|
+
if (mapped !== undefined)
|
|
1332
|
+
rule = mapped;
|
|
1333
|
+
}
|
|
1334
|
+
if (path) {
|
|
1335
|
+
this[ZIG].fillPathExternal(path[ZIG], rule);
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
this[ZIG].fill(rule);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
stroke(path) {
|
|
1342
|
+
if (path instanceof Path2D) {
|
|
1343
|
+
this[ZIG].strokePathExternal(path[ZIG]);
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
this[ZIG].stroke();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
isPointInPath(a, b, c, d) {
|
|
1350
|
+
let path = null;
|
|
1351
|
+
let x;
|
|
1352
|
+
let y;
|
|
1353
|
+
let rule = 'nonzero';
|
|
1354
|
+
if (a instanceof Path2D) {
|
|
1355
|
+
path = a;
|
|
1356
|
+
x = b;
|
|
1357
|
+
y = c ?? 0;
|
|
1358
|
+
const r = d;
|
|
1359
|
+
if (r !== undefined) {
|
|
1360
|
+
const mapped = HTML5_TO_FILLRULE[r];
|
|
1361
|
+
if (mapped !== undefined)
|
|
1362
|
+
rule = mapped;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
x = a;
|
|
1367
|
+
y = b;
|
|
1368
|
+
const r = c;
|
|
1369
|
+
if (r !== undefined) {
|
|
1370
|
+
const mapped = HTML5_TO_FILLRULE[r];
|
|
1371
|
+
if (mapped !== undefined)
|
|
1372
|
+
rule = mapped;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (path)
|
|
1376
|
+
return this[ZIG].isPointInPathExternal(path[ZIG], x, y, rule);
|
|
1377
|
+
return this[ZIG].isPointInPath(x, y, rule);
|
|
1378
|
+
}
|
|
1379
|
+
isPointInStroke(a, b, c) {
|
|
1380
|
+
if (a instanceof Path2D) {
|
|
1381
|
+
return this[ZIG].isPointInStrokeExternal(a[ZIG], b, c ?? 0);
|
|
1382
|
+
}
|
|
1383
|
+
return this[ZIG].isPointInStroke(a, b);
|
|
1384
|
+
}
|
|
1385
|
+
clip(pathOrRule, maybeRule) {
|
|
1386
|
+
let rule = 'nonzero';
|
|
1387
|
+
let path = null;
|
|
1388
|
+
if (pathOrRule instanceof Path2D) {
|
|
1389
|
+
path = pathOrRule;
|
|
1390
|
+
if (maybeRule !== undefined) {
|
|
1391
|
+
const mapped = HTML5_TO_FILLRULE[maybeRule];
|
|
1392
|
+
if (mapped !== undefined)
|
|
1393
|
+
rule = mapped;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
else if (typeof pathOrRule === 'string') {
|
|
1397
|
+
const mapped = HTML5_TO_FILLRULE[pathOrRule];
|
|
1398
|
+
if (mapped !== undefined)
|
|
1399
|
+
rule = mapped;
|
|
1400
|
+
}
|
|
1401
|
+
if (path) {
|
|
1402
|
+
this[ZIG].clipPath(path[ZIG], rule);
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
this[ZIG].clip(rule);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// ---- Text -------------------------------------------------------------
|
|
1409
|
+
get font() { return this.#fontStr; }
|
|
1410
|
+
set font(v) {
|
|
1411
|
+
const parsed = parseCssFont(v);
|
|
1412
|
+
if (!parsed)
|
|
1413
|
+
return; // invalid: spec says ignore
|
|
1414
|
+
this.#fontStr = parsed.canonical;
|
|
1415
|
+
this.#fontInstance = getFontInstance(parsed);
|
|
1416
|
+
}
|
|
1417
|
+
get textAlign() { return this.#textAlign; }
|
|
1418
|
+
set textAlign(v) {
|
|
1419
|
+
if (TEXT_ALIGNS.has(v)) {
|
|
1420
|
+
this.#textAlign = v;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
get textBaseline() { return this.#textBaseline; }
|
|
1424
|
+
set textBaseline(v) {
|
|
1425
|
+
if (TEXT_BASELINES.has(v)) {
|
|
1426
|
+
this.#textBaseline = v;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
get direction() { return this.#direction; }
|
|
1430
|
+
set direction(v) {
|
|
1431
|
+
if (DIRECTIONS.has(v)) {
|
|
1432
|
+
this.#direction = v;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
get letterSpacing() { return this.#letterSpacing; }
|
|
1436
|
+
set letterSpacing(v) {
|
|
1437
|
+
const px = parseCssLengthPx(v);
|
|
1438
|
+
if (px === null)
|
|
1439
|
+
return;
|
|
1440
|
+
this.#letterSpacingPx = px;
|
|
1441
|
+
this.#letterSpacing = canonicalLengthPx(px);
|
|
1442
|
+
}
|
|
1443
|
+
get wordSpacing() { return this.#wordSpacing; }
|
|
1444
|
+
set wordSpacing(v) {
|
|
1445
|
+
const px = parseCssLengthPx(v);
|
|
1446
|
+
if (px === null)
|
|
1447
|
+
return;
|
|
1448
|
+
this.#wordSpacingPx = px;
|
|
1449
|
+
this.#wordSpacing = canonicalLengthPx(px);
|
|
1450
|
+
}
|
|
1451
|
+
get fontKerning() { return this.#fontKerning; }
|
|
1452
|
+
set fontKerning(v) {
|
|
1453
|
+
if (FONT_KERNINGS.has(v)) {
|
|
1454
|
+
this.#fontKerning = v;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
get fontStretch() { return this.#fontStretch; }
|
|
1458
|
+
set fontStretch(v) {
|
|
1459
|
+
// No font-variant infrastructure; accept any string but only round-trip.
|
|
1460
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1461
|
+
this.#fontStretch = v;
|
|
1462
|
+
}
|
|
1463
|
+
get fontVariantCaps() { return this.#fontVariantCaps; }
|
|
1464
|
+
set fontVariantCaps(v) {
|
|
1465
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1466
|
+
this.#fontVariantCaps = v;
|
|
1467
|
+
}
|
|
1468
|
+
get textRendering() { return this.#textRendering; }
|
|
1469
|
+
set textRendering(v) {
|
|
1470
|
+
if (typeof v === 'string' && v.length > 0)
|
|
1471
|
+
this.#textRendering = v;
|
|
1472
|
+
}
|
|
1473
|
+
get filter() { return this.#filter; }
|
|
1474
|
+
set filter(v) {
|
|
1475
|
+
if (typeof v !== 'string' || v.length === 0)
|
|
1476
|
+
return;
|
|
1477
|
+
const parsed = parseCssFilter(v);
|
|
1478
|
+
if (parsed === null)
|
|
1479
|
+
return; // Unparseable: silently ignored per spec.
|
|
1480
|
+
this.#filter = v.trim();
|
|
1481
|
+
const verbs = new Uint8Array(parsed.verbs);
|
|
1482
|
+
const params = new Float64Array(parsed.params);
|
|
1483
|
+
this[ZIG].setFilterChain(verbs, params);
|
|
1484
|
+
}
|
|
1485
|
+
get shadowBlur() { return this.#shadowBlur; }
|
|
1486
|
+
set shadowBlur(v) {
|
|
1487
|
+
if (typeof v !== 'number' || !isFinite(v) || v < 0)
|
|
1488
|
+
return;
|
|
1489
|
+
this.#shadowBlur = v;
|
|
1490
|
+
this[ZIG].shadowBlur = v;
|
|
1491
|
+
}
|
|
1492
|
+
get shadowColor() { return this.#shadowColorStr; }
|
|
1493
|
+
set shadowColor(v) {
|
|
1494
|
+
if (typeof v !== 'string')
|
|
1495
|
+
return;
|
|
1496
|
+
const rgba = parseCssColor(v);
|
|
1497
|
+
if (rgba === null)
|
|
1498
|
+
return;
|
|
1499
|
+
this.#shadowColorStr = rgbaU32ToCssString(rgba);
|
|
1500
|
+
this[ZIG].shadowColor = rgba >>> 0;
|
|
1501
|
+
}
|
|
1502
|
+
get shadowOffsetX() { return this.#shadowOffsetX; }
|
|
1503
|
+
set shadowOffsetX(v) {
|
|
1504
|
+
if (typeof v !== 'number' || !isFinite(v))
|
|
1505
|
+
return;
|
|
1506
|
+
this.#shadowOffsetX = v;
|
|
1507
|
+
this[ZIG].shadowOffsetX = v;
|
|
1508
|
+
}
|
|
1509
|
+
get shadowOffsetY() { return this.#shadowOffsetY; }
|
|
1510
|
+
set shadowOffsetY(v) {
|
|
1511
|
+
if (typeof v !== 'number' || !isFinite(v))
|
|
1512
|
+
return;
|
|
1513
|
+
this.#shadowOffsetY = v;
|
|
1514
|
+
this[ZIG].shadowOffsetY = v;
|
|
1515
|
+
}
|
|
1516
|
+
get imageSmoothingEnabled() { return this.#imageSmoothingEnabled; }
|
|
1517
|
+
set imageSmoothingEnabled(v) {
|
|
1518
|
+
const b = !!v;
|
|
1519
|
+
this.#imageSmoothingEnabled = b;
|
|
1520
|
+
this[ZIG].imageSmoothingEnabled = b;
|
|
1521
|
+
}
|
|
1522
|
+
get imageSmoothingQuality() { return this.#imageSmoothingQuality; }
|
|
1523
|
+
set imageSmoothingQuality(v) {
|
|
1524
|
+
if (IMAGE_SMOOTHING_QUALITIES.has(v)) {
|
|
1525
|
+
this.#imageSmoothingQuality = v;
|
|
1526
|
+
this[ZIG].imageSmoothingQuality = { low: 0, medium: 1, high: 2 }[this.#imageSmoothingQuality];
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
fillText(text, x, y, _maxWidth) {
|
|
1530
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
1531
|
+
return;
|
|
1532
|
+
const { adjX, adjY } = this.#applyTextOffsets(text, x, y);
|
|
1533
|
+
const ls = this.#letterSpacingPx;
|
|
1534
|
+
const ws = this.#wordSpacingPx;
|
|
1535
|
+
const kerning = this.#fontKerning !== 'none';
|
|
1536
|
+
if (ls === 0 && ws === 0 && !kerning) {
|
|
1537
|
+
this[ZIG].fillText(text, adjX, adjY, this.#getFontInstance());
|
|
1538
|
+
}
|
|
1539
|
+
else {
|
|
1540
|
+
this[ZIG].fillTextWithSpacing(text, adjX, adjY, this.#getFontInstance(), ls, ws, kerning);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// strokeText — outlined glyphs require extracting glyph paths from the
|
|
1544
|
+
// font and feeding them through SmScan.strokePath. Not yet implemented;
|
|
1545
|
+
// v1 falls back to fillText so the API surface is callable. Tracked as a
|
|
1546
|
+
// follow-up; visual difference appears at large pt sizes.
|
|
1547
|
+
strokeText(text, x, y, maxWidth) {
|
|
1548
|
+
this.fillText(text, x, y, maxWidth);
|
|
1549
|
+
}
|
|
1550
|
+
measureText(text) {
|
|
1551
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
1552
|
+
return new TextMetrics(0);
|
|
1553
|
+
const ls = this.#letterSpacingPx;
|
|
1554
|
+
const ws = this.#wordSpacingPx;
|
|
1555
|
+
const kerning = this.#fontKerning !== 'none';
|
|
1556
|
+
if (ls === 0 && ws === 0 && !kerning) {
|
|
1557
|
+
return new TextMetrics(this.#getFontInstance().measureWidth(text));
|
|
1558
|
+
}
|
|
1559
|
+
return new TextMetrics(this.#getFontInstance().measureWithSpacing(text, ls, ws, kerning));
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Apply textAlign + textBaseline to the user-supplied (x, y) so the
|
|
1563
|
+
* downstream Zig drawText sees a baseline-aligned, left-anchored pen.
|
|
1564
|
+
*
|
|
1565
|
+
* Per HTML5 spec, when direction='rtl': textAlign='start' aligns right
|
|
1566
|
+
* and textAlign='end' aligns left. 'left'/'right'/'center' are
|
|
1567
|
+
* direction-independent.
|
|
1568
|
+
*/
|
|
1569
|
+
#applyTextOffsets(text, x, y) {
|
|
1570
|
+
let adjX = x;
|
|
1571
|
+
let adjY = y;
|
|
1572
|
+
// Resolve direction-aware textAlign 'start'/'end' to absolute side.
|
|
1573
|
+
const isRtl = this.#direction === 'rtl';
|
|
1574
|
+
let resolved = 'left';
|
|
1575
|
+
switch (this.#textAlign) {
|
|
1576
|
+
case 'left':
|
|
1577
|
+
resolved = 'left';
|
|
1578
|
+
break;
|
|
1579
|
+
case 'right':
|
|
1580
|
+
resolved = 'right';
|
|
1581
|
+
break;
|
|
1582
|
+
case 'center':
|
|
1583
|
+
resolved = 'center';
|
|
1584
|
+
break;
|
|
1585
|
+
case 'start':
|
|
1586
|
+
resolved = isRtl ? 'right' : 'left';
|
|
1587
|
+
break;
|
|
1588
|
+
case 'end':
|
|
1589
|
+
resolved = isRtl ? 'left' : 'right';
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
if (resolved !== 'left') {
|
|
1593
|
+
const ls = this.#letterSpacingPx;
|
|
1594
|
+
const ws = this.#wordSpacingPx;
|
|
1595
|
+
const kerning = this.#fontKerning !== 'none';
|
|
1596
|
+
const w = (ls === 0 && ws === 0 && !kerning)
|
|
1597
|
+
? this.#getFontInstance().measureWidth(text)
|
|
1598
|
+
: this.#getFontInstance().measureWithSpacing(text, ls, ws, kerning);
|
|
1599
|
+
if (resolved === 'right')
|
|
1600
|
+
adjX -= w;
|
|
1601
|
+
else /* center */
|
|
1602
|
+
adjX -= w / 2;
|
|
1603
|
+
}
|
|
1604
|
+
// Vertical baseline. SmCanvas.drawText takes y at the alphabetic
|
|
1605
|
+
// baseline, so 'alphabetic' is the no-op base.
|
|
1606
|
+
const m = this.#getFontInstance().getMetrics();
|
|
1607
|
+
switch (this.#textBaseline) {
|
|
1608
|
+
case 'alphabetic':
|
|
1609
|
+
break;
|
|
1610
|
+
case 'top':
|
|
1611
|
+
case 'hanging': // hanging is for Devanagari etc; approximate as 'top'.
|
|
1612
|
+
adjY += m.ascent;
|
|
1613
|
+
break;
|
|
1614
|
+
case 'middle':
|
|
1615
|
+
adjY += (m.ascent + m.descent) / 2;
|
|
1616
|
+
break;
|
|
1617
|
+
case 'bottom':
|
|
1618
|
+
case 'ideographic': // approximate as 'bottom'.
|
|
1619
|
+
adjY += m.descent;
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
return { adjX, adjY };
|
|
1623
|
+
}
|
|
1624
|
+
createImageData(arg1, height, settings) {
|
|
1625
|
+
if (typeof arg1 === 'number') {
|
|
1626
|
+
if (typeof height !== 'number') {
|
|
1627
|
+
throw new TypeError('createImageData: height required when first arg is a number');
|
|
1628
|
+
}
|
|
1629
|
+
return new ImageData(arg1, height, settings);
|
|
1630
|
+
}
|
|
1631
|
+
if (arg1 instanceof ImageData) {
|
|
1632
|
+
return new ImageData(arg1.width, arg1.height, {
|
|
1633
|
+
colorSpace: arg1.colorSpace,
|
|
1634
|
+
pixelFormat: arg1.pixelFormat,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
throw new TypeError('createImageData: expected number or ImageData');
|
|
1638
|
+
}
|
|
1639
|
+
getImageData(sx, sy, sw, sh, settings) {
|
|
1640
|
+
const z = settings === undefined
|
|
1641
|
+
? this[ZIG].getImageData(sx, sy, sw, sh)
|
|
1642
|
+
: this[ZIG].getImageDataSettings(sx, sy, sw, sh, settings);
|
|
1643
|
+
return ImageData[FROM_ZIG](z);
|
|
1644
|
+
}
|
|
1645
|
+
putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyW, dirtyH) {
|
|
1646
|
+
if (dirtyX === undefined) {
|
|
1647
|
+
this[ZIG].writePixels(imageData[ZIG], dx, dy);
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
this[ZIG].writePixelsDirty(imageData[ZIG], dx, dy, dirtyX, dirtyY ?? 0, dirtyW ?? imageData.width, dirtyH ?? imageData.height);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
drawImage(image, a, b, c, d, e, f, g, h) {
|
|
1654
|
+
let bitmap;
|
|
1655
|
+
let snapshot = false;
|
|
1656
|
+
if (image instanceof Canvas) {
|
|
1657
|
+
// Snapshot the source canvas's surface contents.
|
|
1658
|
+
bitmap = image[ZIG].getCanvas().getImageData(0, 0, image.width, image.height);
|
|
1659
|
+
snapshot = true;
|
|
1660
|
+
}
|
|
1661
|
+
else if (image instanceof ImageData || image instanceof Image) {
|
|
1662
|
+
bitmap = image[ZIG];
|
|
1663
|
+
}
|
|
1664
|
+
else {
|
|
1665
|
+
throw new TypeError('drawImage: expected ImageData, Image, or Canvas');
|
|
1666
|
+
}
|
|
1667
|
+
if (c === undefined) {
|
|
1668
|
+
this[ZIG].drawImageAt(bitmap, a, b);
|
|
1669
|
+
}
|
|
1670
|
+
else if (e === undefined) {
|
|
1671
|
+
this[ZIG].drawImageScaled(bitmap, a, b, c, d);
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
this[ZIG].drawImageScaledSub(bitmap, a, b, c, d, e, f, g, h);
|
|
1675
|
+
}
|
|
1676
|
+
if (snapshot)
|
|
1677
|
+
SmBitmap.release(bitmap);
|
|
1678
|
+
}
|
|
1679
|
+
// ---- Gradients --------------------------------------------------------
|
|
1680
|
+
createLinearGradient(x0, y0, x1, y1) {
|
|
1681
|
+
return new CanvasGradient(SmGradient.linear(x0, y0, x1, y1));
|
|
1682
|
+
}
|
|
1683
|
+
createRadialGradient(x0, y0, r0, x1, y1, r1) {
|
|
1684
|
+
return new CanvasGradient(SmGradient.radial(x0, y0, r0, x1, y1, r1));
|
|
1685
|
+
}
|
|
1686
|
+
createConicGradient(startAngle, x, y) {
|
|
1687
|
+
return new CanvasGradient(SmGradient.conic(startAngle, x, y));
|
|
1688
|
+
}
|
|
1689
|
+
// ---- Patterns ---------------------------------------------------------
|
|
1690
|
+
// image: ImageData / Image / another Canvas. HTMLImageElement / Blob / URL
|
|
1691
|
+
// require an HTTP-flavoured loader we don't ship — decode bytes via
|
|
1692
|
+
// Image.fromBytes first and pass the resulting Image here.
|
|
1693
|
+
// repetition: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat'. Empty
|
|
1694
|
+
// string and null both default to 'repeat' per HTML5 spec.
|
|
1695
|
+
createPattern(image, repetition) {
|
|
1696
|
+
const repKey = repetition === '' || repetition == null ? 'repeat' : repetition;
|
|
1697
|
+
const repEnum = REPETITION_TO_ENUM[repKey];
|
|
1698
|
+
if (repEnum === undefined) {
|
|
1699
|
+
throw new DOMException(`createPattern: invalid repetition '${repetition}'`, 'SyntaxError');
|
|
1700
|
+
}
|
|
1701
|
+
let bytes;
|
|
1702
|
+
let width;
|
|
1703
|
+
let height;
|
|
1704
|
+
let snapshot = null;
|
|
1705
|
+
if (image instanceof Canvas) {
|
|
1706
|
+
// Snapshot via the same path drawImage uses; release the snapshot
|
|
1707
|
+
// immediately because SmPattern.create copies the bytes.
|
|
1708
|
+
snapshot = image[ZIG].getCanvas().getImageData(0, 0, image.width, image.height);
|
|
1709
|
+
bytes = snapshot.data;
|
|
1710
|
+
width = snapshot.width;
|
|
1711
|
+
height = snapshot.height;
|
|
1712
|
+
}
|
|
1713
|
+
else if (image instanceof ImageData || image instanceof Image) {
|
|
1714
|
+
bytes = image[ZIG].data;
|
|
1715
|
+
width = image[ZIG].width;
|
|
1716
|
+
height = image[ZIG].height;
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
throw new TypeError('createPattern: expected ImageData, Image, or Canvas');
|
|
1720
|
+
}
|
|
1721
|
+
// SmPattern.create copies the buffer; safe to release the snapshot now.
|
|
1722
|
+
const pattern = SmPattern.create(bytes, width, height, repEnum);
|
|
1723
|
+
if (snapshot)
|
|
1724
|
+
SmBitmap.release(snapshot);
|
|
1725
|
+
return new CanvasPattern(pattern);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
// =============================================================================
|
|
1729
|
+
// HTML5: Canvas (HTMLCanvasElement-shaped)
|
|
1730
|
+
// =============================================================================
|
|
1731
|
+
export class Canvas {
|
|
1732
|
+
/** @internal */ [ZIG];
|
|
1733
|
+
#ctx = null;
|
|
1734
|
+
#destroyed = false;
|
|
1735
|
+
constructor(width, height, opts) {
|
|
1736
|
+
this[ZIG] = SmSurface.initDefault(width, height);
|
|
1737
|
+
surfaceRegistry.register(this, this[ZIG], this);
|
|
1738
|
+
if (opts?.fonts) {
|
|
1739
|
+
for (const f of opts.fonts) {
|
|
1740
|
+
registerFont(f.data, f.name, { weight: f.weight, style: f.style });
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
get width() { return this[ZIG].width; }
|
|
1745
|
+
set width(n) { this.#resize(n, this[ZIG].height); }
|
|
1746
|
+
get height() { return this[ZIG].height; }
|
|
1747
|
+
set height(n) { this.#resize(this[ZIG].width, n); }
|
|
1748
|
+
// HTML5 spec: assigning width or height — even to the same value —
|
|
1749
|
+
// reallocates the bitmap (transparent black) AND resets the rendering
|
|
1750
|
+
// context state. The cached ctx instance is preserved so user code that
|
|
1751
|
+
// captured it before the resize keeps working; the JS-side mirror
|
|
1752
|
+
// fields are re-synced via the existing `reset()` method.
|
|
1753
|
+
#resize(w, h) {
|
|
1754
|
+
const sw = Number.isFinite(w) && w > 0 ? Math.floor(w) : 0;
|
|
1755
|
+
const sh = Number.isFinite(h) && h > 0 ? Math.floor(h) : 0;
|
|
1756
|
+
this[ZIG].resize(sw, sh);
|
|
1757
|
+
if (this.#ctx)
|
|
1758
|
+
this.#ctx.reset();
|
|
1759
|
+
}
|
|
1760
|
+
getContext(kind, _attrs) {
|
|
1761
|
+
if (kind !== '2d')
|
|
1762
|
+
throw new Error('UnsupportedContext');
|
|
1763
|
+
if (!this.#ctx) {
|
|
1764
|
+
this.#ctx = new CanvasRenderingContext2D(this, this[ZIG].getCanvas());
|
|
1765
|
+
}
|
|
1766
|
+
return this.#ctx;
|
|
1767
|
+
}
|
|
1768
|
+
toDataURL(type, quality) {
|
|
1769
|
+
const t = (type ?? 'image/png').toLowerCase();
|
|
1770
|
+
if (t === 'image/jpeg' || t === 'image/jpg') {
|
|
1771
|
+
const q = clampJpegQuality(quality);
|
|
1772
|
+
const bytes = this[ZIG].encodeJpeg(q);
|
|
1773
|
+
if (bytes.length === 0)
|
|
1774
|
+
return 'data:,';
|
|
1775
|
+
return 'data:image/jpeg;base64,' + bytesToBase64(bytes);
|
|
1776
|
+
}
|
|
1777
|
+
// PNG default — also covers unrecognized mime types per HTML5 fallback.
|
|
1778
|
+
const bytes = this[ZIG].encodePng();
|
|
1779
|
+
if (bytes.length === 0)
|
|
1780
|
+
return 'data:,';
|
|
1781
|
+
return 'data:image/png;base64,' + bytesToBase64(bytes);
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Encode the canvas and return the raw bytes. Skips the base64 round-trip
|
|
1785
|
+
* that `toDataURL()` does — pass directly to `new Response(...)`,
|
|
1786
|
+
* `fs.writeFile`, etc.
|
|
1787
|
+
*
|
|
1788
|
+
* `type` defaults to `'image/png'`; `'image/jpeg'` is also supported with
|
|
1789
|
+
* an optional `quality` in the HTML5 0.0–1.0 range (default 0.92).
|
|
1790
|
+
*
|
|
1791
|
+
* The returned `Uint8Array` is a JS-owned defensive copy; safe to retain
|
|
1792
|
+
* past further drawing.
|
|
1793
|
+
*/
|
|
1794
|
+
toBytes(type, quality) {
|
|
1795
|
+
const t = (type ?? 'image/png').toLowerCase();
|
|
1796
|
+
if (t === 'image/jpeg' || t === 'image/jpg') {
|
|
1797
|
+
return zigBytesToU8(this[ZIG].encodeJpeg(clampJpegQuality(quality)));
|
|
1798
|
+
}
|
|
1799
|
+
return zigBytesToU8(this[ZIG].encodePng());
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Promise-returning sibling of `toBytes`. Mirrors `@napi-rs/canvas`'s
|
|
1803
|
+
* `encode(format)`: encodes off the JS thread on Node (real pthread
|
|
1804
|
+
* offload via zigar's WorkQueue — equivalent to N-API `AsyncWorker`).
|
|
1805
|
+
*
|
|
1806
|
+
* On WASM targets — browsers, Cloudflare Workers, edge runtimes — the
|
|
1807
|
+
* Zig WorkQueue is comptime-gated out and this falls through to a
|
|
1808
|
+
* microtask-yielded sync encode. CF Workers can't spawn worker threads
|
|
1809
|
+
* in any case, so the API shape stays uniform; only the offload behavior
|
|
1810
|
+
* differs by target.
|
|
1811
|
+
*/
|
|
1812
|
+
async toBytesAsync(type, quality) {
|
|
1813
|
+
const t = (type ?? 'image/png').toLowerCase();
|
|
1814
|
+
if (t === 'image/jpeg' || t === 'image/jpg') {
|
|
1815
|
+
if (typeof encodeJpegAsync === 'function') {
|
|
1816
|
+
return zigBytesToU8(await encodeJpegAsync(this[ZIG], clampJpegQuality(quality)));
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
else if (typeof encodePngAsync === 'function') {
|
|
1820
|
+
return zigBytesToU8(await encodePngAsync(this[ZIG]));
|
|
1821
|
+
}
|
|
1822
|
+
await Promise.resolve();
|
|
1823
|
+
return this.toBytes(type, quality);
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Promise-returning sibling of `toDataURL`. Built on top of
|
|
1827
|
+
* `toBytesAsync`, so the same target-dependent offload story applies —
|
|
1828
|
+
* see that method's docstring.
|
|
1829
|
+
*/
|
|
1830
|
+
async toDataURLAsync(type, quality) {
|
|
1831
|
+
const t = (type ?? 'image/png').toLowerCase();
|
|
1832
|
+
const bytes = await this.toBytesAsync(t, quality);
|
|
1833
|
+
if (bytes.length === 0)
|
|
1834
|
+
return 'data:,';
|
|
1835
|
+
const mime = (t === 'image/jpeg' || t === 'image/jpg') ? 'image/jpeg' : 'image/png';
|
|
1836
|
+
return `data:${mime};base64,` + Buffer.from(bytes).toString('base64');
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Eagerly release the underlying Zig surface (pixel buffer, cached
|
|
1840
|
+
* SmCanvas, last encoded payload). Idempotent. Calling any other method
|
|
1841
|
+
* after `destroy()` is undefined behavior — the wrapper's Zig handle is
|
|
1842
|
+
* dangling. Mirrors the pdf.js `BaseCanvasFactory#destroy` contract and
|
|
1843
|
+
* supports the Stage-3 `using` syntax (via `Symbol.dispose`).
|
|
1844
|
+
*
|
|
1845
|
+
* Without this, the same cleanup happens lazily through the
|
|
1846
|
+
* FinalizationRegistry when the wrapper is GC'd. Use `destroy()` to
|
|
1847
|
+
* release sooner — large canvases on long-lived servers, request-scoped
|
|
1848
|
+
* canvases under Cloudflare Workers' tight memory caps, etc.
|
|
1849
|
+
*/
|
|
1850
|
+
destroy() {
|
|
1851
|
+
if (this.#destroyed)
|
|
1852
|
+
return;
|
|
1853
|
+
this.#destroyed = true;
|
|
1854
|
+
surfaceRegistry.unregister(this);
|
|
1855
|
+
if (this.#ctx) {
|
|
1856
|
+
this.#ctx.destroy();
|
|
1857
|
+
this.#ctx = null;
|
|
1858
|
+
}
|
|
1859
|
+
// SmSurface.deinit also tears down the cached SmCanvas.
|
|
1860
|
+
this[ZIG].deinit();
|
|
1861
|
+
}
|
|
1862
|
+
[Symbol.dispose]() { this.destroy(); }
|
|
1863
|
+
}
|
|
1864
|
+
// HTML5 toDataURL/toBlob `quality` is a number in [0, 1]. stb expects 1..100.
|
|
1865
|
+
// Non-finite, missing, or out-of-range falls back to 0.92 (matches Chromium).
|
|
1866
|
+
function clampJpegQuality(q) {
|
|
1867
|
+
const f = typeof q === 'number' && Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0.92;
|
|
1868
|
+
return Math.max(1, Math.min(100, Math.round(f * 100)));
|
|
1869
|
+
}
|
|
1870
|
+
// =============================================================================
|
|
1871
|
+
// Public factory + utility re-exports
|
|
1872
|
+
// =============================================================================
|
|
1873
|
+
export function createCanvas(width, height, opts) {
|
|
1874
|
+
return new Canvas(width, height, opts);
|
|
1875
|
+
}
|
|
1876
|
+
// MicroSharp — sharp-shaped fluent image-processing surface, the second
|
|
1877
|
+
// binding on top of the same Zig core. Exposed as a named export from
|
|
1878
|
+
// the package root (alongside `createCanvas`) so Node and WASM consumers
|
|
1879
|
+
// reach it the same way: `import { microsharp } from 'simdra'`.
|
|
1880
|
+
// TS-extension imports rely on `rewriteRelativeImportExtensions` in
|
|
1881
|
+
// tsconfig.core.json — tsc emits `.js` in the build output; dev / test
|
|
1882
|
+
// run the .ts files directly via Node's built-in TS support.
|
|
1883
|
+
export { microsharp, MicroSharpPipeline } from "./microsharp/index.js";
|
|
1884
|
+
export { parseCssColor };
|
|
1885
|
+
/**
|
|
1886
|
+
* Synchronous init from a pre-compiled `WebAssembly.Module`. Designed for
|
|
1887
|
+
* Cloudflare Workers / Vercel Edge — call at module-init scope:
|
|
1888
|
+
*
|
|
1889
|
+
* import { initSync, createCanvas } from 'simdra/wasm';
|
|
1890
|
+
* import wasm from 'simdra/wasm/simdra.wasm';
|
|
1891
|
+
* initSync(wasm);
|
|
1892
|
+
*
|
|
1893
|
+
* Workers forbids `WebAssembly.compile()` on raw bytes, but allows
|
|
1894
|
+
* `new WebAssembly.Instance(precompiledModule, imports)`. The runtime
|
|
1895
|
+
* compiles the imported `.wasm` at deploy time, so this stays sync.
|
|
1896
|
+
*
|
|
1897
|
+
* In dev (`npm test` via node-zigar) zigar loads the module itself, so this
|
|
1898
|
+
* is a no-op.
|
|
1899
|
+
*/
|
|
1900
|
+
export function initSync(mod) {
|
|
1901
|
+
const fn = zig.__initSync;
|
|
1902
|
+
if (fn)
|
|
1903
|
+
fn(mod);
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Async init for environments where `WebAssembly.compile()` is allowed
|
|
1907
|
+
* (Node, browsers). Accepts bytes / Response / a `Module`. Idempotent.
|
|
1908
|
+
*/
|
|
1909
|
+
export default async function init(input) {
|
|
1910
|
+
const fn = zig.__init;
|
|
1911
|
+
if (fn)
|
|
1912
|
+
await fn(input);
|
|
1913
|
+
}
|