@react-x11/components 0.13.0 → 0.14.1

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 (134) hide show
  1. package/dist/html/controls.d.ts +29 -2
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +70 -2
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts +42 -1
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +336 -16
  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 +18 -4
  14. package/dist/html/css/parse.d.ts.map +1 -1
  15. package/dist/html/css/parse.js +66 -28
  16. package/dist/html/css/parse.js.map +1 -1
  17. package/dist/html/css/shapes.d.ts +44 -0
  18. package/dist/html/css/shapes.d.ts.map +1 -0
  19. package/dist/html/css/shapes.js +194 -0
  20. package/dist/html/css/shapes.js.map +1 -0
  21. package/dist/html/css/style.d.ts +128 -15
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +547 -144
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/transform.d.ts +85 -0
  26. package/dist/html/css/transform.d.ts.map +1 -0
  27. package/dist/html/css/transform.js +425 -0
  28. package/dist/html/css/transform.js.map +1 -0
  29. package/dist/html/css/ua.d.ts.map +1 -1
  30. package/dist/html/css/ua.js +14 -2
  31. package/dist/html/css/ua.js.map +1 -1
  32. package/dist/html/css/values.d.ts +4 -0
  33. package/dist/html/css/values.d.ts.map +1 -1
  34. package/dist/html/css/values.js.map +1 -1
  35. package/dist/html/fonts.d.ts +55 -5
  36. package/dist/html/fonts.d.ts.map +1 -1
  37. package/dist/html/fonts.js +284 -23
  38. package/dist/html/fonts.js.map +1 -1
  39. package/dist/html/layout/axes.d.ts +14 -0
  40. package/dist/html/layout/axes.d.ts.map +1 -0
  41. package/dist/html/layout/axes.js +28 -0
  42. package/dist/html/layout/axes.js.map +1 -0
  43. package/dist/html/layout/block.d.ts +104 -12
  44. package/dist/html/layout/block.d.ts.map +1 -1
  45. package/dist/html/layout/block.js +410 -39
  46. package/dist/html/layout/block.js.map +1 -1
  47. package/dist/html/layout/boxes.d.ts +72 -1
  48. package/dist/html/layout/boxes.d.ts.map +1 -1
  49. package/dist/html/layout/boxes.js +40 -2
  50. package/dist/html/layout/boxes.js.map +1 -1
  51. package/dist/html/layout/cache.d.ts +1 -1
  52. package/dist/html/layout/cache.d.ts.map +1 -1
  53. package/dist/html/layout/cache.js +6 -0
  54. package/dist/html/layout/cache.js.map +1 -1
  55. package/dist/html/layout/css-grid.d.ts.map +1 -1
  56. package/dist/html/layout/css-grid.js +144 -24
  57. package/dist/html/layout/css-grid.js.map +1 -1
  58. package/dist/html/layout/flex.d.ts.map +1 -1
  59. package/dist/html/layout/flex.js +585 -85
  60. package/dist/html/layout/flex.js.map +1 -1
  61. package/dist/html/layout/inline.d.ts +3 -0
  62. package/dist/html/layout/inline.d.ts.map +1 -1
  63. package/dist/html/layout/inline.js +144 -54
  64. package/dist/html/layout/inline.js.map +1 -1
  65. package/dist/html/layout/multicol.d.ts +42 -0
  66. package/dist/html/layout/multicol.d.ts.map +1 -0
  67. package/dist/html/layout/multicol.js +897 -0
  68. package/dist/html/layout/multicol.js.map +1 -0
  69. package/dist/html/layout/shaping.d.ts +7 -0
  70. package/dist/html/layout/shaping.d.ts.map +1 -1
  71. package/dist/html/layout/shaping.js +25 -20
  72. package/dist/html/layout/shaping.js.map +1 -1
  73. package/dist/html/layout/table.d.ts.map +1 -1
  74. package/dist/html/layout/table.js +97 -31
  75. package/dist/html/layout/table.js.map +1 -1
  76. package/dist/html/node.d.ts +4 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +319 -47
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +83 -5
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +1474 -217
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/svg.d.ts +39 -9
  85. package/dist/html/svg.d.ts.map +1 -1
  86. package/dist/html/svg.js +482 -37
  87. package/dist/html/svg.js.map +1 -1
  88. package/dist/html/widgets.js +87 -7
  89. package/dist/html/widgets.js.map +1 -1
  90. package/dist/html/woff2.d.ts +14 -0
  91. package/dist/html/woff2.d.ts.map +1 -0
  92. package/dist/html/woff2.js +608 -0
  93. package/dist/html/woff2.js.map +1 -0
  94. package/dist/maps/controller.d.ts +17 -1
  95. package/dist/maps/controller.d.ts.map +1 -1
  96. package/dist/maps/controller.js +27 -7
  97. package/dist/maps/controller.js.map +1 -1
  98. package/dist/richtext/node.d.ts +8 -1
  99. package/dist/richtext/node.d.ts.map +1 -1
  100. package/dist/richtext/node.js +5 -1
  101. package/dist/richtext/node.js.map +1 -1
  102. package/dist/richtext/runs.d.ts +25 -6
  103. package/dist/richtext/runs.d.ts.map +1 -1
  104. package/dist/richtext/runs.js +240 -24
  105. package/dist/richtext/runs.js.map +1 -1
  106. package/package.json +1 -1
  107. package/src/html/controls.ts +85 -2
  108. package/src/html/css/cascade.ts +366 -8
  109. package/src/html/css/color.ts +29 -0
  110. package/src/html/css/parse.ts +71 -31
  111. package/src/html/css/shapes.ts +239 -0
  112. package/src/html/css/style.ts +652 -147
  113. package/src/html/css/transform.ts +459 -0
  114. package/src/html/css/ua.ts +15 -2
  115. package/src/html/css/values.ts +4 -0
  116. package/src/html/fonts.ts +312 -29
  117. package/src/html/layout/axes.ts +62 -0
  118. package/src/html/layout/block.ts +466 -37
  119. package/src/html/layout/boxes.ts +95 -3
  120. package/src/html/layout/cache.ts +6 -0
  121. package/src/html/layout/css-grid.ts +169 -32
  122. package/src/html/layout/flex.ts +686 -79
  123. package/src/html/layout/inline.ts +149 -55
  124. package/src/html/layout/multicol.ts +977 -0
  125. package/src/html/layout/shaping.ts +27 -18
  126. package/src/html/layout/table.ts +105 -35
  127. package/src/html/node.ts +354 -48
  128. package/src/html/paint.ts +1804 -315
  129. package/src/html/svg.ts +573 -36
  130. package/src/html/widgets.ts +95 -9
  131. package/src/html/woff2.ts +612 -0
  132. package/src/maps/controller.ts +28 -7
  133. package/src/richtext/node.ts +13 -2
  134. package/src/richtext/runs.ts +313 -35
package/src/html/fonts.ts CHANGED
@@ -47,12 +47,50 @@
47
47
  // none left the family stays out of the list and the text is set in the
48
48
  // next one, as a browser sets it when it cannot use a font.
49
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
+ //
50
70
  // A family split by `unicode-range` is registered a name per range, since the
51
71
  // font manager picks among one name's faces by weight and slant alone, and
52
72
  // the range that holds the most of the document's characters goes first: the
53
73
  // first family is the one a run is set in, and the others are reached through
54
74
  // the fallback for the characters it lacks.
55
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
+ //
56
94
  // What stays the application's: registering a face adds it to ntk's
57
95
  // fallback chain, where it can supply a glyph no other face has to text
58
96
  // anywhere in the app (react-x11's `loadFont` says so), and nothing is ever
@@ -64,6 +102,7 @@ import type { Element } from 'domhandler';
64
102
  import type { FontFaceRule } from './css/parse.js';
65
103
  import type { ComputedStyle } from './css/style.js';
66
104
  import type { ResourceRequest, ResourceResult } from './resources.js';
105
+ import { isWoff2, sfntFromWoff2 } from './woff2.js';
67
106
 
68
107
  /** What the cascade asks of the document's families (`Cascade`). */
69
108
  export interface FontFamilies {
@@ -108,6 +147,13 @@ interface Face {
108
147
  family: Family;
109
148
  /** Where loading got to. `idle` has not been asked for. */
110
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;
111
157
  }
112
158
 
113
159
  /** A family's faces for one unicode range: one registered name, among whose
@@ -135,6 +181,12 @@ interface Registry {
135
181
  names: Map<string, string>;
136
182
  /** Faces registered, by `faceKey`. */
137
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]>;
138
190
  /** Faces being loaded, by `faceKey`, so a second document waits for the
139
191
  * first document's request rather than making its own. */
140
192
  pending: Map<string, Promise<boolean>>;
@@ -151,6 +203,8 @@ function registryOf(app: object): Registry {
151
203
  next: 0,
152
204
  names: new Map(),
153
205
  ready: new Set(),
206
+ locals: new Map(),
207
+ axes: new Map(),
154
208
  pending: new Map(),
155
209
  warned: false,
156
210
  };
@@ -187,6 +241,14 @@ export class WebFonts implements FontFamilies {
187
241
  * lists that come to one string share an entry, which can ask for a face
188
242
  * a little early; never for one nothing wants. */
189
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;
190
252
  /** The document's characters, as the last `request` saw them. */
191
253
  private _text: string | null = null;
192
254
  private _seen = new Set<number>();
@@ -269,14 +331,17 @@ export class WebFonts implements FontFamilies {
269
331
  order: order++,
270
332
  };
271
333
  for (const m of members) {
272
- const ready = registry?.ready.has(faceKey(name, m.rule)) ?? false;
273
334
  const face: Face = {
274
335
  rule: m.rule,
275
336
  element: m.element,
276
337
  group,
277
338
  family,
278
- state: ready ? 'ready' : 'idle',
339
+ state: 'idle',
340
+ local: null,
341
+ wght: null,
279
342
  };
343
+ if (registry?.ready.has(faceKey(name, m.rule)))
344
+ arrive(face, registry);
280
345
  group.faces.push(face);
281
346
  made.set(m, face);
282
347
  }
@@ -285,6 +350,10 @@ export class WebFonts implements FontFamilies {
285
350
  families.set(key, family);
286
351
  }
287
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
+ }
288
357
  this._faces = declared.map((d) => made.get(d)!);
289
358
  // the ranges are new, and so is what they cover
290
359
  if (this._text !== null) this._measure(this._text, true);
@@ -307,7 +376,15 @@ export class WebFonts implements FontFamilies {
307
376
  }
308
377
  used.push(family);
309
378
  for (const group of orderedGroups(family)) {
310
- 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
+ }
311
388
  }
312
389
  }
313
390
  let mapped = out.length ? out.join(', ') : this._fallback;
@@ -337,6 +414,64 @@ export class WebFonts implements FontFamilies {
337
414
  for (const family of families) family.wants.add(want);
338
415
  }
339
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
+
340
475
  /**
341
476
  * Ask for the faces the styles noted since the last call want, for the
342
477
  * characters `text` holds — the document's own. True when the lists the
@@ -366,6 +501,7 @@ export class WebFonts implements FontFamilies {
366
501
  destroy(): void {
367
502
  this._destroyed = true;
368
503
  this._families.clear();
504
+ this._groups.clear();
369
505
  this._forget();
370
506
  }
371
507
 
@@ -374,6 +510,8 @@ export class WebFonts implements FontFamilies {
374
510
  private _forget(): void {
375
511
  this._memo.clear();
376
512
  this._sources.clear();
513
+ this._axes.clear();
514
+ this._variable = null;
377
515
  }
378
516
 
379
517
  /**
@@ -466,7 +604,7 @@ export class WebFonts implements FontFamilies {
466
604
  const registry = registryOf(app);
467
605
  const key = faceKey(face.group.name, face.rule);
468
606
  if (registry.ready.has(key)) {
469
- face.state = 'ready';
607
+ arrive(face, registry);
470
608
  return 'ready';
471
609
  }
472
610
  face.state = 'loading';
@@ -487,7 +625,7 @@ export class WebFonts implements FontFamilies {
487
625
  };
488
626
 
489
627
  const sources = face.rule.sources.filter(
490
- (s) => s.format === null || READABLE.has(s.format),
628
+ (s) => 'local' in s || s.format === null || READABLE.has(s.format),
491
629
  );
492
630
  let i = 0;
493
631
  let sync = true;
@@ -496,6 +634,18 @@ export class WebFonts implements FontFamilies {
496
634
  const next = (): 'ready' | 'waiting' | 'none' => {
497
635
  while (i < sources.length) {
498
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
+ }
499
649
  let answer: ReturnType<Ask>;
500
650
  try {
501
651
  answer = this._ask({
@@ -507,15 +657,20 @@ export class WebFonts implements FontFamilies {
507
657
  continue;
508
658
  }
509
659
  if (isPromise(answer)) {
510
- answer.then(
511
- (result) => after(this._register(face, result, source.url)),
512
- () => after(false),
513
- );
660
+ answer
661
+ .then((result) => this._register(face, result, source.url))
662
+ .then(after, () => after(false));
663
+ return 'waiting';
664
+ }
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));
514
669
  return 'waiting';
515
670
  }
516
- if (this._register(face, answer, source.url)) {
671
+ if (registered) {
517
672
  finish(true);
518
- if (sync) face.state = 'ready';
673
+ if (sync) arrive(face, registry);
519
674
  else this._settled(face, true);
520
675
  return 'ready';
521
676
  }
@@ -541,27 +696,47 @@ export class WebFonts implements FontFamilies {
541
696
  return outcome === 'ready' ? 'ready' : 'loading';
542
697
  }
543
698
 
544
- /** Register a face's bytes under its group's name. False when the host
545
- * declined, or the font manager could not read them — a `.woff2` on
546
- * macOS, whose CoreText reads no such container, or a file that is not a
547
- * font — or read them and cannot set text in them (`refusal`), and the
548
- * next source is tried. */
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
+ */
549
712
  private _register(
550
713
  face: Face,
551
714
  result: ResourceResult | null,
552
715
  url: string,
553
- ): boolean {
716
+ ): boolean | Promise<boolean> {
554
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 {
555
732
  const { weight, style } = face.rule;
556
733
  const app = this._app as Parameters<typeof loadFont>[0];
557
734
  try {
558
735
  // `loadFont` opens the file first too, and finds this one opened
559
- const refused = refusal(app, openFont(app, result.bytes), this._fallback);
560
- if (refused !== null) {
561
- warnRefused(registryOf(this._app), url, refused);
562
- return false;
563
- }
564
- loadFont(app, result.bytes, {
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, {
565
740
  family: face.group.name,
566
741
  // a range is registered at the weight nearest regular in it: the
567
742
  // font manager picks among a group's faces by distance from one
@@ -569,24 +744,74 @@ export class WebFonts implements FontFamilies {
569
744
  weight: Math.max(weight[0], Math.min(400, weight[1])),
570
745
  style,
571
746
  });
572
- 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;
573
753
  } catch {
574
- return false;
754
+ return '';
575
755
  }
576
756
  }
577
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
+
578
764
  /** A face arrived, or will not. Either way the boxes are built again: an
579
765
  * arrival changes the lists, and a failure has the next face asked for,
580
766
  * by a family that has to be asked again to ask it. */
581
767
  private _settled(face: Face, ok: boolean): void {
582
768
  if (this._destroyed) return;
583
- face.state = ok ? 'ready' : 'failed';
769
+ if (ok && this._app) arrive(face, registryOf(this._app));
770
+ else face.state = ok ? 'ready' : 'failed';
584
771
  if (!ok) face.family.asked.clear();
585
772
  this._forget();
586
773
  this._changed();
587
774
  }
588
775
  }
589
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
+
590
815
  /** The slice of an opened face `refusal` reads: react-x11's `Font`. */
591
816
  interface OpenedFace {
592
817
  variationAxes?: Record<string, { min: number; default: number; max: number }>;
@@ -677,9 +902,67 @@ function faceKey(name: string, rule: FontFaceRule): string {
677
902
 
678
903
  function faceSignature(rule: FontFaceRule): string {
679
904
  const range = rangeKey(rule.unicodeRange);
680
- return `${rule.sources.map((s) => s.url).join(' ')}@${rule.weight.join(
681
- '-',
682
- )}${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
+ }
683
966
  }
684
967
 
685
968
  function rangeKey(ranges: [number, number][] | null): string {
@@ -0,0 +1,62 @@
1
+ // A text engine that sets a variable web font at the weight CSS says.
2
+ //
3
+ // A style's weight picks a face, and where the face is a variable font it is
4
+ // also a place on the font's `wght` axis — the style's weight, clamped to
5
+ // the range the face's `@font-face` rule declares (CSS Fonts 4, 7.2). The
6
+ // rule is the document's and not the engine's, so which value that is is
7
+ // the document's to say (`WebFonts.wght`), and it is said with the run: a
8
+ // run's `variations`, which both engines take, and which wins over whatever
9
+ // an engine would have done with the weight by itself. ntk moves the axis
10
+ // to the style's weight, past a rule that declared less of it; CoreText,
11
+ // handed a face react-x11 registered, does not move it at all, and every
12
+ // weight of a page set in Geist was the regular on macOS.
13
+ //
14
+ // Only a run whose list leads with such a face is touched, and it is handed
15
+ // on as a copy: the run is the caller's, and the layouts kept from one pass
16
+ // to the next are found by its fields (`TextLayoutCache`), which this is
17
+ // below and adds nothing to. A run in any other family carries no
18
+ // `variations` — an axis value set on the system's own variable font would
19
+ // be this document's rule applied to a font it never declared.
20
+ import type { TextRun } from '../../richtext/index.js';
21
+ import type { FontsLike } from './inline.js';
22
+ import { withLayout } from './shaping.js';
23
+
24
+ /** What says where on its weight axis text is set: `WebFonts`. */
25
+ export interface WeightAxes {
26
+ /** Whether any family has an axis to set. While none has, the engine is
27
+ * called as it is. */
28
+ readonly variable: boolean;
29
+ wght(list: string, weight: number, italic: boolean): { wght: number } | null;
30
+ }
31
+
32
+ const NUMERIC: Record<string, number> = { normal: 400, bold: 700 };
33
+
34
+ /** `engine`, laying each run out at the weight axis value `axes` gives its
35
+ * family list, weight and slant. */
36
+ export function weightAxes(engine: FontsLike, axes: WeightAxes): FontsLike {
37
+ return withLayout(engine, (content, style, options) => {
38
+ if (!axes.variable || !Array.isArray(content)) {
39
+ return engine.layout(content, style, options);
40
+ }
41
+ let runs: TextRun[] | null = null;
42
+ for (let i = 0; i < content.length; i += 1) {
43
+ const run = content[i];
44
+ const family = run.family ?? style.family;
45
+ const weight = run.weight ?? style.weight ?? 400;
46
+ const axis =
47
+ typeof family === 'string'
48
+ ? axes.wght(
49
+ family,
50
+ typeof weight === 'number'
51
+ ? weight
52
+ : (NUMERIC[weight as string] ?? 400),
53
+ (run.style ?? style.style) === 'italic',
54
+ )
55
+ : null;
56
+ if (!axis) continue;
57
+ runs ??= content.slice();
58
+ runs[i] = { ...run, variations: axis } as TextRun;
59
+ }
60
+ return engine.layout(runs ?? content, style, options);
61
+ });
62
+ }