@react-x11/components 0.12.0 → 0.14.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.
Files changed (150) hide show
  1. package/dist/html/controls.d.ts +41 -5
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +85 -57
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts +134 -20
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +545 -126
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/color.d.ts +18 -0
  10. package/dist/html/css/color.d.ts.map +1 -1
  11. package/dist/html/css/color.js +27 -0
  12. package/dist/html/css/color.js.map +1 -1
  13. package/dist/html/css/parse.d.ts +47 -6
  14. package/dist/html/css/parse.d.ts.map +1 -1
  15. package/dist/html/css/parse.js +336 -30
  16. package/dist/html/css/parse.js.map +1 -1
  17. package/dist/html/css/style.d.ts +142 -15
  18. package/dist/html/css/style.d.ts.map +1 -1
  19. package/dist/html/css/style.js +563 -147
  20. package/dist/html/css/style.js.map +1 -1
  21. package/dist/html/css/transform.d.ts +85 -0
  22. package/dist/html/css/transform.d.ts.map +1 -0
  23. package/dist/html/css/transform.js +425 -0
  24. package/dist/html/css/transform.js.map +1 -0
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -9
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +4 -0
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js.map +1 -1
  32. package/dist/html/dom.d.ts.map +1 -1
  33. package/dist/html/dom.js +3 -1
  34. package/dist/html/dom.js.map +1 -1
  35. package/dist/html/fonts.d.ts +78 -4
  36. package/dist/html/fonts.d.ts.map +1 -1
  37. package/dist/html/fonts.js +373 -19
  38. package/dist/html/fonts.js.map +1 -1
  39. package/dist/html/form.d.ts +200 -0
  40. package/dist/html/form.d.ts.map +1 -0
  41. package/dist/html/form.js +801 -0
  42. package/dist/html/form.js.map +1 -0
  43. package/dist/html/index.d.ts +12 -0
  44. package/dist/html/index.d.ts.map +1 -1
  45. package/dist/html/index.js +30 -301
  46. package/dist/html/index.js.map +1 -1
  47. package/dist/html/layout/axes.d.ts +14 -0
  48. package/dist/html/layout/axes.d.ts.map +1 -0
  49. package/dist/html/layout/axes.js +28 -0
  50. package/dist/html/layout/axes.js.map +1 -0
  51. package/dist/html/layout/block.d.ts +93 -12
  52. package/dist/html/layout/block.d.ts.map +1 -1
  53. package/dist/html/layout/block.js +393 -39
  54. package/dist/html/layout/block.js.map +1 -1
  55. package/dist/html/layout/boxes.d.ts +90 -2
  56. package/dist/html/layout/boxes.d.ts.map +1 -1
  57. package/dist/html/layout/boxes.js +74 -10
  58. package/dist/html/layout/boxes.js.map +1 -1
  59. package/dist/html/layout/cache.d.ts +1 -1
  60. package/dist/html/layout/cache.d.ts.map +1 -1
  61. package/dist/html/layout/cache.js +6 -0
  62. package/dist/html/layout/cache.js.map +1 -1
  63. package/dist/html/layout/css-grid.d.ts.map +1 -1
  64. package/dist/html/layout/css-grid.js +90 -18
  65. package/dist/html/layout/css-grid.js.map +1 -1
  66. package/dist/html/layout/flex.d.ts.map +1 -1
  67. package/dist/html/layout/flex.js +574 -84
  68. package/dist/html/layout/flex.js.map +1 -1
  69. package/dist/html/layout/inline.d.ts +3 -0
  70. package/dist/html/layout/inline.d.ts.map +1 -1
  71. package/dist/html/layout/inline.js +154 -54
  72. package/dist/html/layout/inline.js.map +1 -1
  73. package/dist/html/layout/multicol.d.ts +42 -0
  74. package/dist/html/layout/multicol.d.ts.map +1 -0
  75. package/dist/html/layout/multicol.js +897 -0
  76. package/dist/html/layout/multicol.js.map +1 -0
  77. package/dist/html/layout/shaping.d.ts +7 -0
  78. package/dist/html/layout/shaping.d.ts.map +1 -1
  79. package/dist/html/layout/shaping.js +25 -20
  80. package/dist/html/layout/shaping.js.map +1 -1
  81. package/dist/html/layout/table.d.ts.map +1 -1
  82. package/dist/html/layout/table.js +62 -18
  83. package/dist/html/layout/table.js.map +1 -1
  84. package/dist/html/node.d.ts +90 -15
  85. package/dist/html/node.d.ts.map +1 -1
  86. package/dist/html/node.js +771 -186
  87. package/dist/html/node.js.map +1 -1
  88. package/dist/html/paint.d.ts +101 -5
  89. package/dist/html/paint.d.ts.map +1 -1
  90. package/dist/html/paint.js +1502 -160
  91. package/dist/html/paint.js.map +1 -1
  92. package/dist/html/svg.d.ts +15 -2
  93. package/dist/html/svg.d.ts.map +1 -1
  94. package/dist/html/svg.js +249 -17
  95. package/dist/html/svg.js.map +1 -1
  96. package/dist/html/widgets.d.ts +29 -0
  97. package/dist/html/widgets.d.ts.map +1 -0
  98. package/dist/html/widgets.js +700 -0
  99. package/dist/html/widgets.js.map +1 -0
  100. package/dist/html/woff2.d.ts +14 -0
  101. package/dist/html/woff2.d.ts.map +1 -0
  102. package/dist/html/woff2.js +608 -0
  103. package/dist/html/woff2.js.map +1 -0
  104. package/dist/index.d.ts +1 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/maps/controller.d.ts +17 -1
  108. package/dist/maps/controller.d.ts.map +1 -1
  109. package/dist/maps/controller.js +27 -7
  110. package/dist/maps/controller.js.map +1 -1
  111. package/dist/richtext/node.d.ts +8 -1
  112. package/dist/richtext/node.d.ts.map +1 -1
  113. package/dist/richtext/node.js +5 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/richtext/runs.d.ts +25 -6
  116. package/dist/richtext/runs.d.ts.map +1 -1
  117. package/dist/richtext/runs.js +240 -24
  118. package/dist/richtext/runs.js.map +1 -1
  119. package/package.json +2 -2
  120. package/src/html/controls.ts +114 -51
  121. package/src/html/css/cascade.ts +614 -140
  122. package/src/html/css/color.ts +29 -0
  123. package/src/html/css/parse.ts +388 -33
  124. package/src/html/css/style.ts +688 -150
  125. package/src/html/css/transform.ts +459 -0
  126. package/src/html/css/ua.ts +77 -10
  127. package/src/html/css/values.ts +4 -0
  128. package/src/html/dom.ts +3 -1
  129. package/src/html/fonts.ts +417 -24
  130. package/src/html/form.ts +962 -0
  131. package/src/html/index.ts +49 -338
  132. package/src/html/layout/axes.ts +62 -0
  133. package/src/html/layout/block.ts +447 -37
  134. package/src/html/layout/boxes.ts +143 -14
  135. package/src/html/layout/cache.ts +6 -0
  136. package/src/html/layout/css-grid.ts +95 -22
  137. package/src/html/layout/flex.ts +675 -78
  138. package/src/html/layout/inline.ts +159 -55
  139. package/src/html/layout/multicol.ts +977 -0
  140. package/src/html/layout/shaping.ts +27 -18
  141. package/src/html/layout/table.ts +62 -19
  142. package/src/html/node.ts +798 -172
  143. package/src/html/paint.ts +1742 -241
  144. package/src/html/svg.ts +277 -19
  145. package/src/html/widgets.ts +821 -0
  146. package/src/html/woff2.ts +612 -0
  147. package/src/index.ts +1 -0
  148. package/src/maps/controller.ts +28 -7
  149. package/src/richtext/node.ts +13 -2
  150. package/src/richtext/runs.ts +313 -35
package/src/html/fonts.ts CHANGED
@@ -35,23 +35,74 @@
35
35
  // keyed by a family list, the text layouts', the metrics', ntk's, that the
36
36
  // text has to be set again.
37
37
  //
38
+ // **A face the text engine cannot set is not registered.** A face is drawn by
39
+ // the engine, and an engine can read a file it cannot draw every weight of:
40
+ // ntk cuts an instance out of a variable face for the weight and the size a
41
+ // style asks for, and fontkit, which does the cutting, could not cut one out
42
+ // of a WOFF2 — the container nearly every variable web font is served in.
43
+ // The throw came out of the first text layout in the family, and the
44
+ // document it took down was nextjs.org's blog, for Geist. So a face is asked
45
+ // for an instance once, before it is registered (`refusal`), and one the
46
+ // engine refuses is a source that did not load: the next is tried, and with
47
+ // none left the family stays out of the list and the text is set in the
48
+ // next one, as a browser sets it when it cannot use a font.
49
+ //
50
+ // **A WOFF2 an engine does not take is handed over as the font inside it.**
51
+ // The web serves a font as WOFF2 and as little else, and not every engine
52
+ // reads one: CoreText reads an sfnt and nothing more, so react-x11's
53
+ // `loadFont` throws for a WOFF2 on macOS, and the fontkit ntk cuts a
54
+ // variable face with cut nothing out of one. Either way the page was set in
55
+ // its fallback — nextjs.org in Arial, the family its `local()` names, where
56
+ // a browser sets it in Geist. So a file is offered as it was served, and
57
+ // where that is refused the sfnt it wraps is rebuilt (`woff2.ts`) and
58
+ // offered in its place (`_register`); only a font refused both ways is a
59
+ // source that did not load. Nothing asks which engine it is.
60
+ //
61
+ // **A variable face is set at the weight its rule has for a style's.** The
62
+ // weight a style asks for is a place on the face's `wght` axis, clamped to
63
+ // the range its `@font-face` declares (CSS Fonts 4, 7.2), and the rule is
64
+ // the document's: the value is said here (`wght`) and handed to the engine
65
+ // with the run (`layout/axes.ts`). Left alone, ntk moves the axis to the
66
+ // style's weight whatever the rule declared, and CoreText, for a face
67
+ // react-x11 registered, does not move it — every weight of Geist was its
68
+ // regular on macOS.
69
+ //
38
70
  // A family split by `unicode-range` is registered a name per range, since the
39
71
  // font manager picks among one name's faces by weight and slant alone, and
40
72
  // the range that holds the most of the document's characters goes first: the
41
73
  // first family is the one a run is set in, and the others are reached through
42
74
  // the fallback for the characters it lacks.
43
75
  //
76
+ // **A family the document declares is never the system's by that name.**
77
+ // CSS Fonts 4 (5.2) has a family defined by `@font-face` with no face
78
+ // present treated as missing, and forbids matching a platform font of the
79
+ // same name. That holds for a family whose sources are all `local()`s too,
80
+ // which is the one that used to get past: next/font declares
81
+ // `"GeistSans Fallback"` as `src: local("Arial")`, the rule was dropped for
82
+ // having no `url()`, and the name went to the text engine as written —
83
+ // where fontconfig's nearest guess for a family nobody has was Hiragino Sans
84
+ // at 400 and Gill Sans Ultra Bold at 450.
85
+ //
86
+ // A `local()` is a source like a `url()`, tried in the order `src` lists
87
+ // them, and what it comes to is **an alias**: a face the system has goes
88
+ // into the list as its family's name (`localFamily`), in the place of the
89
+ // document's, and nothing is registered. A font manager has no lookup by a
90
+ // face's name — it answers any family with its best guess — so the question
91
+ // is put as a match, and the answer is believed only when the face that
92
+ // comes back says it is the one named.
93
+ //
44
94
  // What stays the application's: registering a face adds it to ntk's
45
95
  // fallback chain, where it can supply a glyph no other face has to text
46
96
  // anywhere in the app (react-x11's `loadFont` says so), and nothing is ever
47
97
  // unregistered — the font manager has no way to. A session that visits many
48
98
  // sites keeps their faces, one registration per distinct declaration.
49
- import { loadFont } from 'react-x11';
99
+ import { loadFont, openFont } from 'react-x11';
50
100
  import type { Element } from 'domhandler';
51
101
 
52
102
  import type { FontFaceRule } from './css/parse.js';
53
103
  import type { ComputedStyle } from './css/style.js';
54
104
  import type { ResourceRequest, ResourceResult } from './resources.js';
105
+ import { isWoff2, sfntFromWoff2 } from './woff2.js';
55
106
 
56
107
  /** What the cascade asks of the document's families (`Cascade`). */
57
108
  export interface FontFamilies {
@@ -96,6 +147,13 @@ interface Face {
96
147
  family: Family;
97
148
  /** Where loading got to. `idle` has not been asked for. */
98
149
  state: 'idle' | 'loading' | 'ready' | 'failed';
150
+ /** The system's family a `ready` face came to through a `local()`, which
151
+ * the list names in place of the group's; null for a face registered
152
+ * from a file. */
153
+ local: string | null;
154
+ /** The range of the `wght` axis a `ready` face's file has, or null for a
155
+ * file with none: a static face, or the system's. */
156
+ wght: [number, number] | null;
99
157
  }
100
158
 
101
159
  /** A family's faces for one unicode range: one registered name, among whose
@@ -123,9 +181,17 @@ interface Registry {
123
181
  names: Map<string, string>;
124
182
  /** Faces registered, by `faceKey`. */
125
183
  ready: Set<string>;
184
+ /** The ones among them that are the system's (`local()`), and registered
185
+ * under no name: the family each came to. */
186
+ locals: Map<string, string>;
187
+ /** The ones whose file is a variable font with a `wght` axis, and the
188
+ * axis's range. */
189
+ axes: Map<string, [number, number]>;
126
190
  /** Faces being loaded, by `faceKey`, so a second document waits for the
127
191
  * first document's request rather than making its own. */
128
192
  pending: Map<string, Promise<boolean>>;
193
+ /** Whether a face the engine refused has been said (`warnRefused`). */
194
+ warned: boolean;
129
195
  }
130
196
 
131
197
  const REGISTRIES = new WeakMap<object, Registry>();
@@ -137,7 +203,10 @@ function registryOf(app: object): Registry {
137
203
  next: 0,
138
204
  names: new Map(),
139
205
  ready: new Set(),
206
+ locals: new Map(),
207
+ axes: new Map(),
140
208
  pending: new Map(),
209
+ warned: false,
141
210
  };
142
211
  REGISTRIES.set(app, registry);
143
212
  }
@@ -172,6 +241,14 @@ export class WebFonts implements FontFamilies {
172
241
  * lists that come to one string share an entry, which can ask for a face
173
242
  * a little early; never for one nothing wants. */
174
243
  private _sources = new Map<string, Family[]>();
244
+ /** The groups by the names they are registered under, which is how a
245
+ * list the engine is handed names one (`wght`). */
246
+ private _groups = new Map<string, Group>();
247
+ /** Lists → weight and slant → the axis value text in them is set at. */
248
+ private _axes = new Map<string, Map<number, WeightAxis | null>>();
249
+ /** Whether any loaded face has a weight axis to set; null for not yet
250
+ * asked since the faces changed. */
251
+ private _variable: boolean | null = null;
175
252
  /** The document's characters, as the last `request` saw them. */
176
253
  private _text: string | null = null;
177
254
  private _seen = new Set<number>();
@@ -254,14 +331,17 @@ export class WebFonts implements FontFamilies {
254
331
  order: order++,
255
332
  };
256
333
  for (const m of members) {
257
- const ready = registry?.ready.has(faceKey(name, m.rule)) ?? false;
258
334
  const face: Face = {
259
335
  rule: m.rule,
260
336
  element: m.element,
261
337
  group,
262
338
  family,
263
- state: ready ? 'ready' : 'idle',
339
+ state: 'idle',
340
+ local: null,
341
+ wght: null,
264
342
  };
343
+ if (registry?.ready.has(faceKey(name, m.rule)))
344
+ arrive(face, registry);
265
345
  group.faces.push(face);
266
346
  made.set(m, face);
267
347
  }
@@ -270,6 +350,10 @@ export class WebFonts implements FontFamilies {
270
350
  families.set(key, family);
271
351
  }
272
352
  this._families = families;
353
+ this._groups.clear();
354
+ for (const family of families.values()) {
355
+ for (const group of family.groups) this._groups.set(group.name, group);
356
+ }
273
357
  this._faces = declared.map((d) => made.get(d)!);
274
358
  // the ranges are new, and so is what they cover
275
359
  if (this._text !== null) this._measure(this._text, true);
@@ -292,7 +376,15 @@ export class WebFonts implements FontFamilies {
292
376
  }
293
377
  used.push(family);
294
378
  for (const group of orderedGroups(family)) {
295
- if (group.faces.some((f) => f.state === 'ready')) out.push(group.name);
379
+ // the name its files are registered under, then the families its
380
+ // `local()`s came to
381
+ const ready = group.faces.filter((f) => f.state === 'ready');
382
+ if (ready.some((f) => f.local === null)) out.push(group.name);
383
+ for (const face of ready) {
384
+ if (face.local !== null && !out.includes(face.local)) {
385
+ out.push(face.local);
386
+ }
387
+ }
296
388
  }
297
389
  }
298
390
  let mapped = out.length ? out.join(', ') : this._fallback;
@@ -322,6 +414,64 @@ export class WebFonts implements FontFamilies {
322
414
  for (const family of families) family.wants.add(want);
323
415
  }
324
416
 
417
+ /** Whether any face the document has loaded is a variable font whose rule
418
+ * declares a range of weights: whether `wght` has anything to say. */
419
+ get variable(): boolean {
420
+ if (this._variable === null) {
421
+ this._variable = false;
422
+ for (const group of this._groups.values()) {
423
+ if (group.faces.some((f) => ranged(f) !== null)) this._variable = true;
424
+ }
425
+ }
426
+ return this._variable;
427
+ }
428
+
429
+ /**
430
+ * Where on its weight axis text in a family list — one `map` made — is
431
+ * set at a weight and slant, or null where no axis is this document's to
432
+ * set.
433
+ *
434
+ * CSS has the weight a style asks for applied to a variable face's `wght`
435
+ * axis, clamped to the range its `@font-face` rule declares (CSS Fonts 4,
436
+ * 7.2): `font-weight: 100 900` is every weight the file has, and
437
+ * under `font-weight: 400 700` text at 900 is set at 700. The rule is the
438
+ * document's, so the value is said here and handed to the engine with the
439
+ * run (`layout/axes.ts`). An engine left to itself either moves the axis
440
+ * to the style's weight, past what the rule declared — ntk — or not at
441
+ * all: a face react-x11 registers with CoreText is drawn at its file's
442
+ * default, and every weight of Geist on macOS was the regular.
443
+ *
444
+ * Only the list's first family is asked, the one text is set in, and only
445
+ * a face whose rule declares a range: one declared at a single weight is
446
+ * left as the engine sets it.
447
+ */
448
+ wght(list: string, weight: number, italic: boolean): WeightAxis | null {
449
+ if (!this.variable) return null;
450
+ let byFace = this._axes.get(list);
451
+ if (!byFace) this._axes.set(list, (byFace = new Map()));
452
+ const key = italic ? -weight : weight;
453
+ let axis = byFace.get(key);
454
+ if (axis === undefined) {
455
+ axis = null;
456
+ const comma = list.indexOf(',');
457
+ const first = (comma < 0 ? list : list.slice(0, comma)).trim();
458
+ const face = bestFace(
459
+ // the faces registered under the name, which the engine picks among
460
+ this._groups
461
+ .get(first)
462
+ ?.faces.filter((f) => f.state === 'ready' && f.local === null) ?? [],
463
+ weight,
464
+ italic,
465
+ );
466
+ const range = face && ranged(face);
467
+ if (range) {
468
+ axis = weightAxis(Math.max(range[0], Math.min(weight, range[1])));
469
+ }
470
+ byFace.set(key, axis);
471
+ }
472
+ return axis;
473
+ }
474
+
325
475
  /**
326
476
  * Ask for the faces the styles noted since the last call want, for the
327
477
  * characters `text` holds — the document's own. True when the lists the
@@ -351,6 +501,7 @@ export class WebFonts implements FontFamilies {
351
501
  destroy(): void {
352
502
  this._destroyed = true;
353
503
  this._families.clear();
504
+ this._groups.clear();
354
505
  this._forget();
355
506
  }
356
507
 
@@ -359,6 +510,8 @@ export class WebFonts implements FontFamilies {
359
510
  private _forget(): void {
360
511
  this._memo.clear();
361
512
  this._sources.clear();
513
+ this._axes.clear();
514
+ this._variable = null;
362
515
  }
363
516
 
364
517
  /**
@@ -451,7 +604,7 @@ export class WebFonts implements FontFamilies {
451
604
  const registry = registryOf(app);
452
605
  const key = faceKey(face.group.name, face.rule);
453
606
  if (registry.ready.has(key)) {
454
- face.state = 'ready';
607
+ arrive(face, registry);
455
608
  return 'ready';
456
609
  }
457
610
  face.state = 'loading';
@@ -472,7 +625,7 @@ export class WebFonts implements FontFamilies {
472
625
  };
473
626
 
474
627
  const sources = face.rule.sources.filter(
475
- (s) => s.format === null || READABLE.has(s.format),
628
+ (s) => 'local' in s || s.format === null || READABLE.has(s.format),
476
629
  );
477
630
  let i = 0;
478
631
  let sync = true;
@@ -481,6 +634,18 @@ export class WebFonts implements FontFamilies {
481
634
  const next = (): 'ready' | 'waiting' | 'none' => {
482
635
  while (i < sources.length) {
483
636
  const source = sources[i++];
637
+ if ('local' in source) {
638
+ // nothing to ask the host for, and nothing to wait on
639
+ const family = this._destroyed
640
+ ? null
641
+ : localFamily(app, source.local, face.rule);
642
+ if (family === null) continue;
643
+ registry.locals.set(key, family);
644
+ finish(true);
645
+ if (sync) arrive(face, registry);
646
+ else this._settled(face, true);
647
+ return 'ready';
648
+ }
484
649
  let answer: ReturnType<Ask>;
485
650
  try {
486
651
  answer = this._ask({
@@ -492,15 +657,20 @@ export class WebFonts implements FontFamilies {
492
657
  continue;
493
658
  }
494
659
  if (isPromise(answer)) {
495
- answer.then(
496
- (result) => after(this._register(face, result)),
497
- () => after(false),
498
- );
660
+ answer
661
+ .then((result) => this._register(face, result, source.url))
662
+ .then(after, () => after(false));
499
663
  return 'waiting';
500
664
  }
501
- if (this._register(face, answer)) {
665
+ const registered = this._register(face, answer, source.url);
666
+ if (isPromise(registered)) {
667
+ // a WOFF2 being handed over as the font inside it
668
+ registered.then(after, () => after(false));
669
+ return 'waiting';
670
+ }
671
+ if (registered) {
502
672
  finish(true);
503
- if (sync) face.state = 'ready';
673
+ if (sync) arrive(face, registry);
504
674
  else this._settled(face, true);
505
675
  return 'ready';
506
676
  }
@@ -526,15 +696,47 @@ export class WebFonts implements FontFamilies {
526
696
  return outcome === 'ready' ? 'ready' : 'loading';
527
697
  }
528
698
 
529
- /** Register a face's bytes under its group's name. False when the host
530
- * declined, or the font manager could not read them — a `.woff2` on
531
- * macOS, whose CoreText reads no such container, or a file that is not a
532
- * font — and the next source is tried. */
533
- private _register(face: Face, result: ResourceResult | null): boolean {
699
+ /**
700
+ * Register a face's bytes under its group's name. False when the host
701
+ * declined, or the font manager could not read them — a file that is not
702
+ * a font — or read them and cannot set text in them (`refusal`), and the
703
+ * next source is tried.
704
+ *
705
+ * A WOFF2 the engine does not take as served is handed over again as the
706
+ * font inside it (`woff2.ts`), and the answer is then a promise: CoreText
707
+ * reads no such container, so react-x11's `loadFont` refuses every one on
708
+ * macOS, and an ntk whose fontkit cuts no instance out of one cuts it out
709
+ * of the sfnt. Nothing here asks which engine it is: the file is offered
710
+ * as the web served it, and rebuilt only for an engine that said no.
711
+ */
712
+ private _register(
713
+ face: Face,
714
+ result: ResourceResult | null,
715
+ url: string,
716
+ ): boolean | Promise<boolean> {
534
717
  if (this._destroyed || !this._app || result?.kind !== 'font') return false;
718
+ const refused = this._set(face, result.bytes);
719
+ if (refused === null) return true;
720
+ if (!isWoff2(result.bytes)) return this._declined(url, refused);
721
+ return sfntFromWoff2(result.bytes).then((sfnt) => {
722
+ if (this._destroyed || !this._app) return false;
723
+ const still = sfnt ? this._set(face, sfnt) : refused;
724
+ return still === null || this._declined(url, still);
725
+ });
726
+ }
727
+
728
+ /** Hand a font file to the font manager, under a face's group's name.
729
+ * Null when it registered; otherwise why the engine cannot set text in
730
+ * it (`refusal`), or `''` for bytes it could not read at all. */
731
+ private _set(face: Face, bytes: Uint8Array): string | null {
535
732
  const { weight, style } = face.rule;
733
+ const app = this._app as Parameters<typeof loadFont>[0];
536
734
  try {
537
- loadFont(this._app as Parameters<typeof loadFont>[0], result.bytes, {
735
+ // `loadFont` opens the file first too, and finds this one opened
736
+ const opened: OpenedFace = openFont(app, bytes);
737
+ const refused = refusal(app, opened, this._fallback);
738
+ if (refused !== null) return refused;
739
+ loadFont(app, bytes, {
538
740
  family: face.group.name,
539
741
  // a range is registered at the weight nearest regular in it: the
540
742
  // font manager picks among a group's faces by distance from one
@@ -542,24 +744,157 @@ export class WebFonts implements FontFamilies {
542
744
  weight: Math.max(weight[0], Math.min(400, weight[1])),
543
745
  style,
544
746
  });
545
- return true;
747
+ const axis = opened.variationAxes?.wght;
748
+ if (axis && axis.min < axis.max) {
749
+ const key = faceKey(face.group.name, face.rule);
750
+ registryOf(this._app!).axes.set(key, [axis.min, axis.max]);
751
+ }
752
+ return null;
546
753
  } catch {
547
- return false;
754
+ return '';
548
755
  }
549
756
  }
550
757
 
758
+ /** A source did not register, and the one the engine refused is said. */
759
+ private _declined(url: string, why: string): false {
760
+ if (why && this._app) warnRefused(registryOf(this._app), url, why);
761
+ return false;
762
+ }
763
+
551
764
  /** A face arrived, or will not. Either way the boxes are built again: an
552
765
  * arrival changes the lists, and a failure has the next face asked for,
553
766
  * by a family that has to be asked again to ask it. */
554
767
  private _settled(face: Face, ok: boolean): void {
555
768
  if (this._destroyed) return;
556
- face.state = ok ? 'ready' : 'failed';
769
+ if (ok && this._app) arrive(face, registryOf(this._app));
770
+ else face.state = ok ? 'ready' : 'failed';
557
771
  if (!ok) face.family.asked.clear();
558
772
  this._forget();
559
773
  this._changed();
560
774
  }
561
775
  }
562
776
 
777
+ /** A registered face is the document's to use: what the connection knows
778
+ * of it — the family a `local()` came to, the axis its file has — is the
779
+ * face's. */
780
+ function arrive(face: Face, registry: Registry): void {
781
+ const key = faceKey(face.group.name, face.rule);
782
+ face.state = 'ready';
783
+ face.local = registry.locals.get(key) ?? null;
784
+ face.wght = registry.axes.get(key) ?? null;
785
+ }
786
+
787
+ /** A point on the weight axis, as an engine takes one with a run: its
788
+ * `variations`. */
789
+ export interface WeightAxis {
790
+ wght: number;
791
+ }
792
+
793
+ /** One object a value: ntk tells two runs' variations apart by identity,
794
+ * and would set two runs at one weight as two. */
795
+ const WEIGHTS = new Map<number, WeightAxis>();
796
+
797
+ function weightAxis(wght: number): WeightAxis {
798
+ let axis = WEIGHTS.get(wght);
799
+ if (!axis) WEIGHTS.set(wght, (axis = { wght }));
800
+ return axis;
801
+ }
802
+
803
+ /** The weights a loaded face's axis is set within: the range its rule
804
+ * declares, inside the one its file has. Null for a face with no axis, or
805
+ * declared at a single weight. */
806
+ function ranged(face: Face): [number, number] | null {
807
+ if (face.state !== 'ready' || !face.wght) return null;
808
+ const lo = Math.max(face.rule.weight[0], face.wght[0]);
809
+ const hi = Math.min(face.rule.weight[1], face.wght[1]);
810
+ return face.rule.weight[0] < face.rule.weight[1] && lo <= hi
811
+ ? [lo, hi]
812
+ : null;
813
+ }
814
+
815
+ /** The slice of an opened face `refusal` reads: react-x11's `Font`. */
816
+ interface OpenedFace {
817
+ variationAxes?: Record<string, { min: number; default: number; max: number }>;
818
+ variation?(settings: Record<string, number>): unknown;
819
+ }
820
+
821
+ /** The axes a style moves without naming one: ntk sets `wght` from the
822
+ * weight and `opsz` from the size (its `docs/fonts.md`), and nothing here
823
+ * hands it a `font-variation-settings`. A face with neither is set as its
824
+ * file has it, whatever else varies in it. */
825
+ const DRIVEN_AXES = ['wght', 'opsz'];
826
+
827
+ /**
828
+ * Why the text engine cannot set text in an opened face, or null when it
829
+ * can: the face is asked for the instance a layout will ask it for, at the
830
+ * far end of each axis a style moves, and the answer is the engine's own.
831
+ * What it cost unasked was the document: ntk instantiates inside `match`,
832
+ * so the throw came out of the first layout in the family, at whatever
833
+ * weight was not the file's default.
834
+ *
835
+ * Only an engine that draws through the face is asked. CoreText and
836
+ * DirectWrite move an axis themselves, and the face react-x11 opens there
837
+ * is fontkit's, for an application to read: what it cannot do says nothing
838
+ * about what they draw.
839
+ */
840
+ export function refusal(
841
+ app: object,
842
+ font: OpenedFace,
843
+ family: string,
844
+ ): string | null {
845
+ const axes = font.variationAxes;
846
+ if (!axes || typeof font.variation !== 'function') return null;
847
+ const settings: Record<string, number> = {};
848
+ for (const tag of DRIVEN_AXES) {
849
+ const axis = axes[tag];
850
+ if (!axis) continue;
851
+ const far = axis.max !== axis.default ? axis.max : axis.min;
852
+ if (far !== axis.default) settings[tag] = far;
853
+ }
854
+ if (!Object.keys(settings).length) return null;
855
+ try {
856
+ font.variation(settings);
857
+ return null;
858
+ } catch (error) {
859
+ return instantiates(app, family)
860
+ ? String((error as Error)?.message ?? error)
861
+ : null;
862
+ }
863
+ }
864
+
865
+ /** Whether the engine's faces are cut into instances as an opened one is:
866
+ * the face it matches a family with has `variation`, as ntk's has and a
867
+ * CoreText or DirectWrite one has not. An engine that cannot say — no
868
+ * family to match — is taken to, since what is at stake is the document. */
869
+ function instantiates(app: object, family: string): boolean {
870
+ try {
871
+ const fonts = (app as { fonts?: { match?(family: string): unknown } })
872
+ .fonts;
873
+ const face = fonts?.match?.(family) as OpenedFace | null | undefined;
874
+ return !face || typeof face.variation === 'function';
875
+ } catch {
876
+ return true;
877
+ }
878
+ }
879
+
880
+ /** Said once a connection, in development: a page set in its fallback
881
+ * family looks like a font that never loaded, and this says it did. */
882
+ function warnRefused(registry: Registry, url: string, why: string): void {
883
+ if (registry.warned) return;
884
+ registry.warned = true;
885
+ const g = globalThis as {
886
+ process?: { env?: Record<string, string | undefined> };
887
+ console?: { warn(message: string): void };
888
+ };
889
+ if (g.process?.env?.NODE_ENV === 'production') return;
890
+ g.console?.warn(
891
+ `@react-x11/components: <Html> loaded the font at ${url} and the text ` +
892
+ 'engine cannot set text in it, so its family is left out and the text ' +
893
+ 'is set in the next one — as it is for every face the engine refuses. ' +
894
+ `A variable font served as WOFF2 is the usual cause.\n${why}`,
895
+ );
896
+ }
897
+
563
898
  /** A face's identity within a registered name. */
564
899
  function faceKey(name: string, rule: FontFaceRule): string {
565
900
  return `${name}\u0000${faceSignature(rule)}`;
@@ -567,9 +902,67 @@ function faceKey(name: string, rule: FontFaceRule): string {
567
902
 
568
903
  function faceSignature(rule: FontFaceRule): string {
569
904
  const range = rangeKey(rule.unicodeRange);
570
- return `${rule.sources.map((s) => s.url).join(' ')}@${rule.weight.join(
571
- '-',
572
- )}${rule.style}#${range}`;
905
+ const sources = rule.sources.map((s) =>
906
+ 'local' in s ? `local(${s.local})` : s.url,
907
+ );
908
+ return `${sources.join(' ')}@${rule.weight.join('-')}${rule.style}#${range}`;
909
+ }
910
+
911
+ /** What is asked of a font manager to find a `local()`: a match, and the
912
+ * names of the face it answers with. Both engines' faces carry them. */
913
+ interface FontLookup {
914
+ match?(
915
+ family: string,
916
+ opts: { weight: number; style: string },
917
+ ): { familyName?: string; postscriptName?: string } | null;
918
+ }
919
+
920
+ /**
921
+ * The family of the face a `local()` names, or null when the system has no
922
+ * such face and the next source is tried.
923
+ *
924
+ * CSS Fonts 4 (4.3.1) has the name be one face's — its full name or its
925
+ * PostScript name, never a family's, and never a platform's substitute for
926
+ * it. A font manager has no lookup by either: it takes a family and answers
927
+ * with its best guess, which for a name nobody has is some other font. So
928
+ * the name is matched as a family, at the weight and slant the rule declares
929
+ * its face to have, and the answer counts only when the face that comes back
930
+ * says it is the one asked for: by its family, which is the full name of a
931
+ * family's regular face and how `local()` is nearly always spelled
932
+ * (`local(Arial)`), or by its PostScript name, where an engine finds a face
933
+ * by one. A full name with a style in it (`local(Arial Bold)`) is no family,
934
+ * and is not found.
935
+ *
936
+ * What comes back is the family and not the face: the list names it, and
937
+ * the engine picks among its faces by the weight and slant of the text, as
938
+ * it does for any family. Bold text in a family declared as the one face
939
+ * `local(Arial)` is set in Arial's bold, where a browser emboldens the
940
+ * regular.
941
+ */
942
+ function localFamily(
943
+ app: object,
944
+ name: string,
945
+ rule: FontFaceRule,
946
+ ): string | null {
947
+ const fonts = (app as { fonts?: FontLookup }).fonts;
948
+ if (typeof fonts?.match !== 'function') return null;
949
+ const [lo, hi] = rule.weight;
950
+ try {
951
+ const face = fonts.match(name, {
952
+ weight: Math.max(lo, Math.min(400, hi)),
953
+ style: rule.style,
954
+ });
955
+ const family = face?.familyName;
956
+ if (!family) return null;
957
+ const want = name.toLowerCase();
958
+ return family.toLowerCase() === want ||
959
+ face.postscriptName?.toLowerCase() === want
960
+ ? family
961
+ : null;
962
+ } catch {
963
+ // no fontconfig, or a name it could not read: not found
964
+ return null;
965
+ }
573
966
  }
574
967
 
575
968
  function rangeKey(ranges: [number, number][] | null): string {