react-x11 2.2.0 → 2.3.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.
@@ -0,0 +1,541 @@
1
+ // The Cocoa text engine: CoreText behind the same `app.fonts` contract ntk's
2
+ // FontManager answers on the X11 backend. The renderer touches exactly this
3
+ // surface (docs/macos.md §"Text: the engine contract"):
4
+ //
5
+ // fonts.layout(spans, base, { maxWidth, align, lineHeight, maxLines,
6
+ // overflow, direction })
7
+ // -> { width, height, lines, draw(ctx, x, y),
8
+ // indexAt(x, y), caretPosition(cp) }
9
+ // fonts.match(family, { weight, style }) -> { metrics(size) }
10
+ //
11
+ // Index spaces, because two meet here: `lines[].start/end` and
12
+ // `runs[].start/end` are UTF-16 code units (what `rangeBands` in nodes.js
13
+ // compares against), while `caretPosition()` takes and `indexAt()` returns
14
+ // code points (what the selection and caret code speak). CoreText itself is
15
+ // UTF-16 end to end; the code-point conversion happens at this boundary and
16
+ // nowhere else.
17
+ import { readFileSync } from 'node:fs';
18
+ import { inflateSync } from 'node:zlib';
19
+
20
+ import { cssColorStraight, Font } from 'ntk';
21
+
22
+ import { loadNative } from './native.js';
23
+
24
+ /**
25
+ * WOFF v1 -> sfnt: the same table directory, zlib per table. ~40 lines of
26
+ * unwrapping is what lets every `.woff` an app already ships keep working
27
+ * on this backend without a converter in the build.
28
+ */
29
+ function woffToSfnt(woff) {
30
+ const numTables = woff.readUInt16BE(12);
31
+ const flavor = woff.readUInt32BE(4);
32
+ const entries = [];
33
+ for (let i = 0; i < numTables; i++) {
34
+ const at = 44 + i * 20;
35
+ const compLength = woff.readUInt32BE(at + 8);
36
+ const origLength = woff.readUInt32BE(at + 12);
37
+ const offset = woff.readUInt32BE(at + 4);
38
+ const compressed = woff.subarray(offset, offset + compLength);
39
+ entries.push({
40
+ tag: woff.readUInt32BE(at),
41
+ checksum: woff.readUInt32BE(at + 16),
42
+ data: compLength === origLength ? compressed : inflateSync(compressed),
43
+ origLength,
44
+ });
45
+ }
46
+ const headerSize = 12 + numTables * 16;
47
+ let total = headerSize;
48
+ for (const entry of entries) total += (entry.origLength + 3) & ~3;
49
+ const out = Buffer.alloc(total);
50
+ out.writeUInt32BE(flavor, 0);
51
+ out.writeUInt16BE(numTables, 4);
52
+ const pow2 = 1 << Math.floor(Math.log2(numTables));
53
+ out.writeUInt16BE(pow2 * 16, 6); // searchRange
54
+ out.writeUInt16BE(Math.floor(Math.log2(numTables)), 8); // entrySelector
55
+ out.writeUInt16BE(numTables * 16 - pow2 * 16, 10); // rangeShift
56
+ let dataAt = headerSize;
57
+ entries.forEach((entry, i) => {
58
+ const dir = 12 + i * 16;
59
+ out.writeUInt32BE(entry.tag, dir);
60
+ out.writeUInt32BE(entry.checksum, dir + 4);
61
+ out.writeUInt32BE(dataAt, dir + 8);
62
+ out.writeUInt32BE(entry.origLength, dir + 12);
63
+ entry.data.copy(out, dataAt);
64
+ dataAt += (entry.origLength + 3) & ~3;
65
+ });
66
+ return out;
67
+ }
68
+
69
+ /** family list string -> array: 'Inter, "SF Pro", sans-serif' */
70
+ function familyList(family) {
71
+ if (Array.isArray(family)) return family;
72
+ return String(family ?? 'sans-serif')
73
+ .split(',')
74
+ .map((f) => f.trim().replace(/^["']|["']$/g, ''))
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function numericWeight(weight) {
79
+ if (typeof weight === 'number') return weight;
80
+ if (weight === 'bold') return 700;
81
+ if (weight === 'medium') return 500;
82
+ if (weight === 'semibold') return 600;
83
+ if (weight === 'light') return 300;
84
+ return 400;
85
+ }
86
+
87
+ const isItalic = (style) => style === 'italic' || style === 'oblique';
88
+
89
+ function parseColor(color) {
90
+ const parsed = cssColorStraight(color ?? '#000');
91
+ return parsed ?? [0, 0, 0, 1];
92
+ }
93
+
94
+ /** UTF-16 offset of each code point boundary, plus the end. */
95
+ function codeUnitOffsets(text) {
96
+ const offsets = [0];
97
+ for (const ch of text) offsets.push(offsets[offsets.length - 1] + ch.length);
98
+ return offsets;
99
+ }
100
+
101
+ class CocoaTextLayout {
102
+ constructor(native, raw, text) {
103
+ this._native = native;
104
+ this._handle = raw.handle;
105
+ this._text = text;
106
+ this._cpToCu = codeUnitOffsets(text);
107
+ this.width = raw.width;
108
+ this.height = raw.height;
109
+ this.lines = raw.lines;
110
+ }
111
+
112
+ _cuOf(cp) {
113
+ const t = this._cpToCu;
114
+ return t[Math.max(0, Math.min(cp, t.length - 1))];
115
+ }
116
+
117
+ _cpOf(cu) {
118
+ const t = this._cpToCu;
119
+ // t is sorted; layouts are short, a linear walk is fine
120
+ for (let i = 0; i < t.length; i++) if (t[i] >= cu) return i;
121
+ return t.length - 1;
122
+ }
123
+
124
+ draw(ctx, x, y) {
125
+ ctx._drawLayout(this, x, y);
126
+ }
127
+
128
+ /** Code point boundary nearest the point, in layout coordinates. */
129
+ indexAt(x, y) {
130
+ return this._cpOf(this._native.layoutIndexAt(this._handle, x, y));
131
+ }
132
+
133
+ /** Caret rect for a code-point index: { x, y, height }. */
134
+ caretPosition(cp) {
135
+ return this._native.layoutCaret(this._handle, this._cuOf(cp));
136
+ }
137
+ }
138
+
139
+ const ALIGN_FLUSH = { left: 0, center: 0.5, right: 1 };
140
+
141
+ function flushFor(align, direction) {
142
+ if (align === 'start' || align === undefined) {
143
+ return direction === 'rtl' ? 1 : 0;
144
+ }
145
+ if (align === 'end') return direction === 'rtl' ? 0 : 1;
146
+ return ALIGN_FLUSH[align] ?? 0;
147
+ }
148
+
149
+ const GENERIC_FAMILIES = new Set([
150
+ 'sans-serif',
151
+ 'serif',
152
+ 'monospace',
153
+ 'cursive',
154
+ 'system-ui',
155
+ 'ui-sans-serif',
156
+ 'ui-monospace',
157
+ ]);
158
+
159
+ export class CocoaFontManager {
160
+ constructor() {
161
+ this._native = loadNative();
162
+ this._fonts = new Map(); // family|weight|italic|size -> handle
163
+ this._faces = new Map(); // family|weight|italic -> face wrapper
164
+ this._registered = new Map(); // lowercase family -> [{cg, weight, italic}]
165
+ this._byKey = new Map(); // ntk Font key -> { cg } | { ps }
166
+ this._sized = new Map(); // face key|size|variations -> CTFont handle
167
+ this._layouts = new Map(); // layout signature -> CocoaTextLayout (LRU)
168
+ }
169
+
170
+ /**
171
+ * `openFont()`'s engine seam (src/fonts.js `openThrough`): parse the face
172
+ * with fontkit — the object applications read metrics and axes off — and
173
+ * feed the same bytes to CoreText so the face RENDERS here too, by
174
+ * family name or as a `span.font`. An opened file is a face the user
175
+ * chose to look at; a backend where it measures but draws as the system
176
+ * font answers a different question than the one asked.
177
+ */
178
+ _open(candidate) {
179
+ const { key, path, data, postscriptName } = candidate;
180
+ const font =
181
+ path !== undefined
182
+ ? Font.loadSync(path, postscriptName)
183
+ : Font.fromData(data, candidate);
184
+ try {
185
+ let bytes =
186
+ path !== undefined
187
+ ? readFileSync(path)
188
+ : Buffer.isBuffer(data)
189
+ ? data
190
+ : Buffer.from(data.buffer ?? data);
191
+ const magic = bytes.length >= 4 ? bytes.readUInt32BE(0) : 0;
192
+ if (magic === 0x774f4646) bytes = woffToSfnt(bytes);
193
+ if (magic !== 0x774f4632 /* woff2 stays fontkit-only */) {
194
+ const info = this._native.fontFromData(bytes);
195
+ // a .ttc's data handle is its FIRST face; when a specific face was
196
+ // asked for and this is not it, leave rendering to the PostScript
197
+ // route (installed collections resolve there anyway)
198
+ const face = postscriptName ?? font.postscriptName;
199
+ if (info && (!face || info.postScriptName === face)) {
200
+ this._byKey.set(font.key ?? key, { cg: info.cg });
201
+ const family = (info.familyName ?? '').toLowerCase();
202
+ if (family) {
203
+ const faces = this._registered.get(family) ?? [];
204
+ faces.push({
205
+ cg: info.cg,
206
+ weight: numericWeight(info.weight),
207
+ italic: Boolean(info.italic),
208
+ });
209
+ this._registered.set(family, faces);
210
+ }
211
+ this._fonts.clear();
212
+ this._faces.clear();
213
+ this._sized.clear();
214
+ }
215
+ }
216
+ } catch {
217
+ // the fontkit face still measures; rendering falls back by family
218
+ }
219
+ return font;
220
+ }
221
+
222
+ /**
223
+ * A CTFont for an ntk `Font` face handed over as `span.font` — the way
224
+ * the fonts app renders the face it opened, and the way `loadFont`'s
225
+ * faces reach glyphs. Resolution: an installed face by its exact
226
+ * PostScript name; otherwise the file's own bytes (unwrapping `.woff`),
227
+ * cached per face key.
228
+ */
229
+ _faceFont(face, size, variations) {
230
+ const key = face.key ?? `${face.path ?? ''}#${face.postscriptName ?? ''}`;
231
+ let entry = this._byKey.get(key);
232
+ if (!entry) {
233
+ entry = {};
234
+ const ps = face.postscriptName;
235
+ if (ps && this._native.fontByPostScriptName(ps, 12)) {
236
+ entry.ps = ps;
237
+ } else {
238
+ let data = null;
239
+ if (face.path) {
240
+ try {
241
+ data = readFileSync(face.path);
242
+ } catch {
243
+ data = null;
244
+ }
245
+ }
246
+ if (!data && face.fk?.stream?.buffer) {
247
+ data = Buffer.from(face.fk.stream.buffer);
248
+ }
249
+ if (data) {
250
+ if (data.length >= 4 && data.readUInt32BE(0) === 0x774f4646) {
251
+ data = woffToSfnt(data);
252
+ }
253
+ const info = this._native.fontFromData(data);
254
+ if (info) entry.cg = info.cg;
255
+ }
256
+ }
257
+ this._byKey.set(key, entry);
258
+ }
259
+ const sizedKey = `${key}|${size}|${variations ? JSON.stringify(variations) : ''}`;
260
+ let handle = this._sized.get(sizedKey);
261
+ if (handle) return handle;
262
+ if (entry.ps) handle = this._native.fontByPostScriptName(entry.ps, size);
263
+ else if (entry.cg) handle = this._native.cgFontWithSize(entry.cg, size);
264
+ if (!handle) return null;
265
+ handle = this._withVariations(handle, variations);
266
+ this._sized.set(sizedKey, handle);
267
+ return handle;
268
+ }
269
+
270
+ _withVariations(handle, variations) {
271
+ if (
272
+ variations &&
273
+ typeof variations === 'object' &&
274
+ Object.keys(variations).length > 0
275
+ ) {
276
+ return this._native.fontApplyVariations(handle, variations);
277
+ }
278
+ return handle;
279
+ }
280
+
281
+ /**
282
+ * The catalogue seam ntk exposes as `fonts.source` — what the fonts app
283
+ * browses. On X that is fontconfig; here it is CoreText's collection.
284
+ * Pattern syntax: the family, with fontconfig's `:modifiers` tolerated
285
+ * and ignored (`Menlo:bold`, `:lang=ru` — the part after the colon is
286
+ * fontconfig vocabulary CoreText does not speak).
287
+ */
288
+ get source() {
289
+ return (this._source ??= {
290
+ matchSortedAsync: async ({ family } = {}) => {
291
+ const pattern = String(family ?? '').trim();
292
+ let name = pattern.split(':')[0].trim();
293
+ if (name && GENERIC_FAMILIES.has(name.toLowerCase())) {
294
+ // Rendering resolves generics to the system face, but its family
295
+ // is a hidden name (`.AppleSystemUIFont`) the catalogue cannot
296
+ // enumerate — a browser wants the visible families instead.
297
+ name =
298
+ {
299
+ serif: 'Times New Roman',
300
+ monospace: 'Menlo',
301
+ 'ui-monospace': 'Menlo',
302
+ cursive: 'Snell Roundhand',
303
+ }[name.toLowerCase()] ?? 'Helvetica Neue';
304
+ }
305
+ const rows = this._native.listFonts(
306
+ name ? { family: name } : { limit: 400 },
307
+ );
308
+ return rows
309
+ .filter((row) => row.path)
310
+ .map((row) => ({
311
+ path: row.path,
312
+ postscriptName: row.postScriptName,
313
+ family: row.familyName,
314
+ style: row.styleName,
315
+ charset: '',
316
+ }));
317
+ },
318
+ });
319
+ }
320
+
321
+ _font(family, weight, italic, size) {
322
+ const key = `${family}|${weight}|${italic}|${size}`;
323
+ let handle = this._fonts.get(key);
324
+ if (!handle) {
325
+ // Faces the app loaded win over system matching for their family —
326
+ // an app that ships a font means the one it ships (src/fonts.js).
327
+ for (const name of familyList(family)) {
328
+ const faces = this._registered.get(name.toLowerCase());
329
+ if (!faces?.length) continue;
330
+ let best = null;
331
+ let bestCost = Infinity;
332
+ for (const face of faces) {
333
+ const cost =
334
+ Math.abs(face.weight - weight) +
335
+ (face.italic === italic ? 0 : 1000);
336
+ if (cost < bestCost) {
337
+ bestCost = cost;
338
+ best = face;
339
+ }
340
+ }
341
+ handle = this._native.cgFontWithSize(best.cg, size);
342
+ break;
343
+ }
344
+ handle ??= this._native.matchFont({
345
+ families: familyList(family),
346
+ size,
347
+ weight,
348
+ italic,
349
+ });
350
+ this._fonts.set(key, handle);
351
+ }
352
+ return handle;
353
+ }
354
+
355
+ /**
356
+ * A face by family/weight/style — what `textBoxTrim` reads `capHeight`
357
+ * from. The face defers size, like ntk's: `metrics(size)` answers for a
358
+ * concrete pixel size.
359
+ */
360
+ match(family, { weight, style } = {}) {
361
+ const w = numericWeight(weight);
362
+ const italic = isItalic(style);
363
+ const key = `${family}|${w}|${italic}`;
364
+ let face = this._faces.get(key);
365
+ if (!face) {
366
+ const manager = this;
367
+ face = {
368
+ metrics(size) {
369
+ return manager._native.fontMetrics(
370
+ manager._font(family, w, italic, size ?? 14),
371
+ );
372
+ },
373
+ hasGlyph(char) {
374
+ return manager._native.fontHasGlyph(
375
+ manager._font(family, w, italic, 14),
376
+ String(char),
377
+ );
378
+ },
379
+ };
380
+ this._faces.set(key, face);
381
+ }
382
+ return face;
383
+ }
384
+
385
+ /**
386
+ * `loadFont()`'s engine half: register the face with CoreText so the
387
+ * process can match it by family name (with real weight/italic traits —
388
+ * they are read off the file, so four weights of one family resolve the
389
+ * way `fontWeight` expects). `source` is a path or the font bytes, the
390
+ * two shapes `src/fonts.js` hands over.
391
+ *
392
+ * Container rule: CoreText reads sfnt (ttf/otf/ttc). A `.woff` is those
393
+ * same tables zlib-wrapped and is unwrapped here; a `.woff2` is a
394
+ * different compression CoreText cannot take and this engine does not
395
+ * rebuild — the error says what to load instead.
396
+ */
397
+ load(source, opts = {}) {
398
+ let data;
399
+ if (typeof source === 'string') {
400
+ data = readFileSync(source);
401
+ } else if (Buffer.isBuffer(source)) {
402
+ data = source;
403
+ } else if (source instanceof Uint8Array) {
404
+ data = Buffer.from(source.buffer, source.byteOffset, source.byteLength);
405
+ } else {
406
+ throw new Error(
407
+ 'react-x11: loadFont — expected a file path or font bytes, got ' +
408
+ typeof source,
409
+ );
410
+ }
411
+ const magic = data.length >= 4 ? data.readUInt32BE(0) : 0;
412
+ if (magic === 0x774f4632 /* wOF2 */) {
413
+ throw new Error(
414
+ 'react-x11: loadFont — this is a .woff2 file, and the macOS font ' +
415
+ 'loader (CoreText) does not read that container. Load the .ttf, ' +
416
+ '.otf or .woff of the same face instead — @fontsource packages ' +
417
+ 'ship a .woff beside every .woff2.',
418
+ );
419
+ }
420
+ if (magic === 0x774f4646 /* wOFF */) data = woffToSfnt(data);
421
+ const info = this._native.fontFromData(data);
422
+ if (!info) {
423
+ throw new Error(
424
+ 'react-x11: loadFont — CoreText could not read the font data' +
425
+ (typeof source === 'string' ? ` in ${source}` : '') +
426
+ ' (expected .ttf, .otf, .ttc or .woff).',
427
+ );
428
+ }
429
+ // The process's own handle to the face, kept in a registry family
430
+ // matching consults FIRST — CoreText registration of in-memory data is
431
+ // best-effort at most, and rendering must not depend on it.
432
+ const family = (opts.family ?? info.familyName ?? '').toLowerCase();
433
+ if (family) {
434
+ const faces = this._registered.get(family) ?? [];
435
+ faces.push({
436
+ cg: info.cg,
437
+ weight: numericWeight(opts.weight ?? info.weight),
438
+ italic: opts.style ? isItalic(opts.style) : Boolean(info.italic),
439
+ });
440
+ this._registered.set(family, faces);
441
+ }
442
+ // matches resolved before this face existed are stale now
443
+ this._fonts.clear();
444
+ this._faces.clear();
445
+ this._sized.clear();
446
+ this._layouts.clear();
447
+ // `null` on purpose: src/fonts.js keeps the fontkit face it already
448
+ // opened as the handle, which is the one whose metrics apps can read;
449
+ // the registry above is what rendering resolves against.
450
+ return null;
451
+ }
452
+
453
+ layout(spans, base, options = {}) {
454
+ // ntk's contract: a bare string or a single span are one-span
455
+ // paragraphs. TextInputNode's value layout passes the string form, and
456
+ // iterating a string as spans was a caret pinned to x = 0.
457
+ if (typeof spans === 'string') spans = [{ text: spans }];
458
+ else if (!Array.isArray(spans)) spans = [spans];
459
+ // Memoized: an immediate-mode caller (the fonts app's specimen canvas,
460
+ // a hover pass) lays the same paragraph out every repaint, and a
461
+ // CTFramesetter per pointer move is what sluggish feels like. The key
462
+ // is everything shaping reads; ~64 entries covers a screenful.
463
+ const signature = JSON.stringify([
464
+ spans.map((sp) => [
465
+ sp.text,
466
+ sp.family ?? base.family,
467
+ sp.size ?? base.size,
468
+ sp.weight ?? base.weight,
469
+ sp.style ?? base.style,
470
+ sp.color ?? base.color ?? null,
471
+ sp.variations ?? base.variations ?? null,
472
+ (sp.font ?? base.font)?.key ?? null,
473
+ ]),
474
+ options.maxWidth,
475
+ options.align,
476
+ options.lineHeight,
477
+ options.maxLines,
478
+ options.overflow,
479
+ options.direction,
480
+ ]);
481
+ const hit = this._layouts.get(signature);
482
+ if (hit) {
483
+ // refresh LRU position
484
+ this._layouts.delete(signature);
485
+ this._layouts.set(signature, hit);
486
+ return hit;
487
+ }
488
+ const { maxWidth, align, lineHeight, maxLines, overflow, direction } =
489
+ options;
490
+ const nativeSpans = [];
491
+ let text = '';
492
+ let contextInk = false;
493
+ for (const span of spans) {
494
+ const t = String(span.text ?? '');
495
+ if (!t) continue;
496
+ text += t;
497
+ const size = span.size ?? base.size ?? 14;
498
+ const variations = span.variations ?? base.variations;
499
+ // A span may carry the face itself — an ntk Font from openFont(),
500
+ // which is how the fonts app renders exactly the file it opened.
501
+ const face = span.font ?? base.font;
502
+ let handle =
503
+ face && (face.postscriptName || face.path || face.fk)
504
+ ? this._faceFont(face, size, variations)
505
+ : null;
506
+ handle ??= this._withVariations(
507
+ this._font(
508
+ span.family ?? base.family,
509
+ numericWeight(span.weight ?? base.weight),
510
+ isItalic(span.style ?? base.style),
511
+ size,
512
+ ),
513
+ variations,
514
+ );
515
+ const color = span.color ?? base.color;
516
+ if (color == null) contextInk = true;
517
+ nativeSpans.push({
518
+ text: t,
519
+ font: handle,
520
+ ...(color == null ? {} : { color: parseColor(color) }),
521
+ });
522
+ }
523
+ const raw = this._native.createLayout({
524
+ spans: nativeSpans,
525
+ maxWidth:
526
+ Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : undefined,
527
+ align: flushFor(align, direction),
528
+ lineHeight: typeof lineHeight === 'number' ? lineHeight : undefined,
529
+ maxLines: Number.isFinite(maxLines) ? maxLines : undefined,
530
+ ellipsis: overflow === 'ellipsis',
531
+ rtl: direction === 'rtl',
532
+ });
533
+ const layout = new CocoaTextLayout(this._native, raw, text);
534
+ layout._contextInk = contextInk;
535
+ this._layouts.set(signature, layout);
536
+ if (this._layouts.size > 64) {
537
+ this._layouts.delete(this._layouts.keys().next().value);
538
+ }
539
+ return layout;
540
+ }
541
+ }