react-x11 2.40.0 → 2.41.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.
@@ -72,15 +72,77 @@ const GENERIC = new Set([
72
72
  'system-ui',
73
73
  'cursive',
74
74
  ]);
75
- function familyOf(native, stack) {
76
- if (!stack) return 'sans-serif';
75
+ //
76
+ // Two more ways a name reaches a face, both what Chrome does on Windows:
77
+ //
78
+ // - **A face of a family filed under another name.** DirectWrite groups
79
+ // faces by weight, width and slope, so "Arial Black", "Segoe UI Light" and
80
+ // "Franklin Gothic Medium" are faces of "Arial", "Segoe UI" and "Franklin
81
+ // Gothic", and no family by those names exists. The bridge looks such a
82
+ // name up as GDI does (`fontFamilyOf`) and answers the family and the
83
+ // face's weight, width and slope, which then stand in for the request's
84
+ // weight and width, as Blink has them (`font_cache_skia_win.cc`, the weight
85
+ // and width suffixes): `font-weight: bold` on "Arial Black" is still Arial
86
+ // Black. A slope is kept from either.
87
+ // - **Blink's alternate names** (`AlternateFamilyName`, font_cache.cc):
88
+ // Courier and Courier New, Times and Times New Roman, Arial and Helvetica,
89
+ // each tried for the other where it is missing. Windows has a Courier only
90
+ // as a bitmap font DirectWrite does not read, and no Times or Helvetica,
91
+ // and these three head a good many stacks on the web.
92
+ const ALTERNATES = new Map([
93
+ ['courier', 'Courier New'],
94
+ ['courier new', 'Courier'],
95
+ ['times', 'Times New Roman'],
96
+ ['times new roman', 'Times'],
97
+ ['arial', 'Helvetica'],
98
+ ['helvetica', 'Arial'],
99
+ ]);
100
+
101
+ /**
102
+ * A stack resolved: `{ family, face }`, the family the bridge is asked for
103
+ * and, where the name reached a face of a family by another name, `face`:
104
+ * its `weight`, `stretch` and `italic`. `face` is null otherwise.
105
+ */
106
+ function resolveStack(native, stack) {
107
+ if (!stack) return { family: 'sans-serif', face: null };
77
108
  for (const raw of String(stack).split(',')) {
78
109
  const name = raw.trim().replace(/^["']|["']$/g, '');
79
110
  if (!name) continue;
80
- if (GENERIC.has(name)) return name;
81
- if (native.fontExists(name)) return name;
111
+ if (GENERIC.has(name)) return { family: name, face: null };
112
+ if (native.fontExists(name)) return { family: name, face: null };
113
+ const face = native.fontFamilyOf?.(name);
114
+ if (face?.family) {
115
+ return {
116
+ family: face.family,
117
+ face: {
118
+ weight: weightOf(face.weight),
119
+ stretch: face.stretch,
120
+ italic: face.italic === true,
121
+ },
122
+ };
123
+ }
124
+ const alternate = ALTERNATES.get(name.toLowerCase());
125
+ if (alternate && native.fontExists(alternate)) {
126
+ return { family: alternate, face: null };
127
+ }
128
+ }
129
+ return { family: 'sans-serif', face: null };
130
+ }
131
+
132
+ /** What a span or a base asks for, with its stack resolved: the family,
133
+ * weight, width and slope the bridge is handed. */
134
+ function requestOf(resolved, weight, style) {
135
+ const italic = style === 'italic' || style === 'oblique';
136
+ const { family, face } = resolved;
137
+ if (!face) {
138
+ return { family, weight: weightOf(weight ?? 400), stretch: 5, italic };
82
139
  }
83
- return 'sans-serif';
140
+ return {
141
+ family,
142
+ weight: face.weight,
143
+ stretch: face.stretch,
144
+ italic: italic || face.italic,
145
+ };
84
146
  }
85
147
 
86
148
  function colorOf(value) {
@@ -93,6 +155,112 @@ function colorOf(value) {
93
155
  }
94
156
  }
95
157
 
158
+ /**
159
+ * A span's letter spacing and OpenType features, as the bridge takes them:
160
+ * `letterSpacing` in pixels after each character, `features` by tag. Letter
161
+ * spacing turns the optional ligatures off, as ntk and the Cocoa engine have
162
+ * it, so the three agree on what a spaced word is made of. Left out, a
163
+ * `letter-spacing` was set tight on Windows, and <Html>, which justifies a
164
+ * line through letter spacing where an engine cannot, laid a justified
165
+ * line's words over each other.
166
+ */
167
+ const OPTIONAL_LIGATURES_OFF = Object.freeze({
168
+ liga: 0,
169
+ clig: 0,
170
+ dlig: 0,
171
+ hlig: 0,
172
+ });
173
+ function spacingOf(span, base) {
174
+ const raw = span.letterSpacing ?? base.letterSpacing;
175
+ const spacing = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
176
+ const own = span.features ?? base.features;
177
+ const features = spacing
178
+ ? own
179
+ ? { ...OPTIONAL_LIGATURES_OFF, ...own }
180
+ : OPTIONAL_LIGATURES_OFF
181
+ : own;
182
+ return {
183
+ ...(spacing ? { letterSpacing: spacing } : {}),
184
+ ...(features && typeof features === 'object' ? { features } : {}),
185
+ };
186
+ }
187
+
188
+ /** What an eliding paragraph ends in. */
189
+ const ELLIPSIS = '…';
190
+
191
+ /**
192
+ * The spans' text over `[from, to)` in code units, each span cut where the
193
+ * range cuts it and a span the range takes whole kept as the caller's own
194
+ * object; `mark` goes on the end of the last, in its style.
195
+ */
196
+ function spansBetween(spans, from, to, mark = '') {
197
+ const out = [];
198
+ let at = 0;
199
+ for (const span of spans) {
200
+ const text = String(span.text);
201
+ const start = at;
202
+ at += text.length;
203
+ if (at <= from) continue;
204
+ if (start >= to) break;
205
+ const piece = text.slice(Math.max(0, from - start), to - start);
206
+ if (piece) out.push(piece === text ? span : { ...span, text: piece });
207
+ }
208
+ if (mark) {
209
+ if (out.length) {
210
+ const last = out[out.length - 1];
211
+ out[out.length - 1] = { ...last, text: String(last.text) + mark };
212
+ } else if (spans.length) {
213
+ out.push({ ...spans[0], text: mark });
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * A line's width without the white space it ends on, from where the layout
221
+ * puts the caret before that white space. Left to right only: on a line a
222
+ * right-to-left run is on, where the space goes is the bidi algorithm's,
223
+ * and the line keeps the bridge's width.
224
+ */
225
+ function inkWidth(native, handle, text, line) {
226
+ const end = trimmedEnd(text, line.start, line.end);
227
+ if (end === line.end) return line.width;
228
+ if (end === line.start) return 0;
229
+ if ((line.runs ?? []).some((run) => run.rtl)) return line.width;
230
+ const caret = native.layoutCaret(handle, end);
231
+ if (!caret || !Number.isFinite(caret.x)) return line.width;
232
+ return Math.max(0, Math.min(line.width, caret.x - line.x));
233
+ }
234
+
235
+ /** The spans up to code unit `to`, with `mark` after them. */
236
+ function spansTo(spans, to, mark = '') {
237
+ return spansBetween(spans, 0, to, mark);
238
+ }
239
+
240
+ /** The white space a line may end on and leaves out of its width: the
241
+ * spaces a line breaks at, and the line separator <Html> asks for a break
242
+ * with — not a no-break space, which is part of the line as a letter is. */
243
+ const BREAKING_SPACE =
244
+ /[\t\n\v\f\r \u1680\u2000-\u200a\u2028\u2029\u205f\u3000]/;
245
+
246
+ /** `end` moved back over the white space before it, not past `start`. */
247
+ function trimmedEnd(text, start, end) {
248
+ let at = end;
249
+ while (at > start && BREAKING_SPACE.test(text[at - 1])) at--;
250
+ return at;
251
+ }
252
+
253
+ /** A code-unit offset not inside a surrogate pair: `at`, or one before. */
254
+ function pointAtOrBefore(text, at) {
255
+ const unit = text.charCodeAt(at - 1);
256
+ return unit >= 0xd800 && unit <= 0xdbff ? at - 1 : at;
257
+ }
258
+
259
+ /** The code-unit offset one code point before `at`. */
260
+ function previousPoint(text, at) {
261
+ return Math.max(0, pointAtOrBefore(text, at - 1));
262
+ }
263
+
96
264
  /** Code-unit offset of each code point, so the two index spaces can be
97
265
  * converted without walking the string twice per query. */
98
266
  function codeUnitOffsets(text) {
@@ -119,6 +287,107 @@ class Win32TextLayout {
119
287
  // rather than from inside the layout — so a <text> whose colour is a
120
288
  // gradient reaches drawLayoutGradient.
121
289
  this._contextInk = true;
290
+ // ntk's: whether a `maxLines` cap dropped any of the text
291
+ this.truncated = false;
292
+ }
293
+
294
+ /**
295
+ * Hang each run off the span it was laid out from, as ntk's runs hang:
296
+ * `span` is the caller's own object, markers and all — what a painter
297
+ * reads a link's underline or a code chip's background off — and `run`
298
+ * the face and size it was set in, and its direction. The bridge makes a
299
+ * run of each stretch of one direction, a link beside plain text in
300
+ * one, so a run is cut where two spans meet, at the offset the layout
301
+ * puts that index at. Without it a document drew no underline, chip or
302
+ * highlight on Windows and could find no link under the pointer.
303
+ *
304
+ * `laid` is what was laid out — `sources` cut at a cap, an ellipsis on
305
+ * the last — span for span, so the offsets are its and the objects the
306
+ * caller's.
307
+ */
308
+ _hangRuns(sources, laid, runOf) {
309
+ const starts = [];
310
+ let at = 0;
311
+ for (const span of laid) {
312
+ starts.push(at);
313
+ at += String(span.text).length;
314
+ }
315
+ for (const line of this.lines) {
316
+ const runs = [];
317
+ for (const r of line.runs) {
318
+ this._cutRun(
319
+ r,
320
+ line,
321
+ starts,
322
+ (i, rtl) => {
323
+ const span = sources[i];
324
+ return { span, run: runOf(span, rtl) };
325
+ },
326
+ runs,
327
+ );
328
+ }
329
+ line.runs = runs;
330
+ }
331
+ }
332
+
333
+ _cutRun(r, line, starts, hangOf, out) {
334
+ if (!starts.length) {
335
+ out.push(r);
336
+ return;
337
+ }
338
+ // the span holding the run's first unit
339
+ let lo = 0;
340
+ let hi = starts.length - 1;
341
+ while (lo < hi) {
342
+ const mid = (lo + hi + 1) >> 1;
343
+ if (starts[mid] <= r.start) lo = mid;
344
+ else hi = mid - 1;
345
+ }
346
+ const hang = (run, source) => {
347
+ const { span, run: set } = hangOf(source, run.rtl);
348
+ run.span = span;
349
+ run.run = set;
350
+ return run;
351
+ };
352
+ let next = lo + 1;
353
+ if (next >= starts.length || starts[next] >= r.end) {
354
+ out.push(hang(r, lo));
355
+ return;
356
+ }
357
+ // where each span that starts inside the run begins, from the line's
358
+ // origin, as the run's own `x` is measured
359
+ const edges = [r.start];
360
+ const at = [r.rtl ? r.x + r.width : r.x];
361
+ const sources = [lo];
362
+ for (; next < starts.length && starts[next] < r.end; next += 1) {
363
+ edges.push(starts[next]);
364
+ const caret = this._native.layoutCaret(this._handle, starts[next]);
365
+ at.push((caret?.x ?? 0) - line.x);
366
+ sources.push(next);
367
+ }
368
+ edges.push(r.end);
369
+ at.push(r.rtl ? r.x : r.x + r.width);
370
+ const pieces = [];
371
+ for (let i = 0; i < sources.length; i += 1) {
372
+ const a = at[i];
373
+ const b = at[i + 1];
374
+ pieces.push(
375
+ hang(
376
+ {
377
+ ...r,
378
+ start: edges[i],
379
+ end: edges[i + 1],
380
+ x: Math.min(a, b),
381
+ width: Math.abs(b - a),
382
+ },
383
+ sources[i],
384
+ ),
385
+ );
386
+ }
387
+ // a line's runs go left to right, and a right-to-left run's first span
388
+ // is its rightmost piece
389
+ if (r.rtl) pieces.reverse();
390
+ out.push(...pieces);
122
391
  }
123
392
 
124
393
  get width() {
@@ -198,12 +467,19 @@ class Win32TextLayout {
198
467
  /** A face, as far as a renderer that positions glyphs itself reads one.
199
468
  * Size is deferred the way ntk's is. */
200
469
  class Win32Face {
201
- constructor(manager, family, { weight = 400, style = 'normal' } = {}) {
470
+ constructor(
471
+ manager,
472
+ family,
473
+ { weight = 400, style = 'normal', stretch = 5 } = {},
474
+ ) {
202
475
  this._manager = manager;
203
476
  this.family = family;
204
477
  this.weight = weightOf(weight);
205
478
  this.style = style;
206
479
  this.italic = style === 'italic' || style === 'oblique';
480
+ // DirectWrite's width, 1–9 with 5 normal: a face such as Arial Narrow is
481
+ // reached through it (`resolveStack`)
482
+ this.stretch = stretch;
207
483
  }
208
484
 
209
485
  /**
@@ -221,7 +497,15 @@ class Win32Face {
221
497
  size,
222
498
  this.weight,
223
499
  this.italic,
500
+ this.stretch,
224
501
  );
502
+ // ntk's `lineHeight` beside DirectWrite's names, as the Cocoa engine
503
+ // gives CoreText's, so a caller written against ntk reads a finite
504
+ // line height: <Html>'s `line-height: normal` read `NaN` here, and a
505
+ // `NaN` in a block's height culled the whole document from the paint.
506
+ if (m && typeof m === 'object' && !Number.isFinite(m.lineHeight)) {
507
+ m = { ...m, lineHeight: m.ascent + m.descent + (m.lineGap || 0) };
508
+ }
225
509
  this._metrics.set(size, m);
226
510
  }
227
511
  return m;
@@ -294,12 +578,18 @@ export class Win32FontManager {
294
578
  */
295
579
  _handleFor(face, size) {
296
580
  const px = Math.max(1, Math.round((size ?? 0) * 64)) / 64;
297
- const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${px}`;
581
+ const stretch = face.stretch ?? 5;
582
+ const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${stretch}|${px}`;
298
583
  let handle = this._handles.get(key);
299
584
  if (handle === undefined) {
300
585
  handle =
301
- this._native.fontHandle(face.family, px, face.weight, face.italic) ||
302
- null;
586
+ this._native.fontHandle(
587
+ face.family,
588
+ px,
589
+ face.weight,
590
+ face.italic,
591
+ stretch,
592
+ ) || null;
303
593
  this._handles.set(key, handle);
304
594
  }
305
595
  return handle;
@@ -370,9 +660,10 @@ export class Win32FontManager {
370
660
  }
371
661
  // Faces matched before this one existed were matched against a smaller
372
662
  // set of fonts, and one of them may have fallen back to what this
373
- // replaces.
663
+ // replaces — a stack resolved past its name among them.
374
664
  this._faces.clear();
375
665
  this._handles.clear();
666
+ this._stacks?.clear();
376
667
  return null;
377
668
  }
378
669
 
@@ -429,10 +720,142 @@ export class Win32FontManager {
429
720
  * layout rather than one per chunk — and therefore what makes line breaking
430
721
  * work *across* them, which is the whole reason the contract takes spans
431
722
  * rather than a string.
723
+ *
724
+ * The cap and the ellipsis are ntk's and are worked out here, since the
725
+ * bridge has neither: the paragraph is broken at the width, the lines past
726
+ * `maxLines` are dropped (`truncated` says whether any were), and an
727
+ * eliding one ends its last line in `…`, as much of the line kept as fits
728
+ * beside it. The bridge's own `maxLines` is never asked: it reads 1 as "do
729
+ * not wrap", so a one-line `<text>` came back as the whole paragraph on
730
+ * one line, as wide as all of it and with no ellipsis, and every paragraph
731
+ * a caller laid out a line at a time through it overran its box.
432
732
  */
433
733
  layout(spans, base = {}, options = {}) {
434
734
  const list =
435
735
  typeof spans === 'string' ? [{ text: spans, ...base }] : (spans ?? []);
736
+ // the caller's spans with text, which the runs hang off (`_hangRuns`)
737
+ const sources = list.filter((span) => String(span?.text ?? ''));
738
+ const { maxWidth, maxLines, overflow } = options;
739
+ const cap = Number.isFinite(maxLines)
740
+ ? Math.max(1, Math.floor(maxLines))
741
+ : Infinity;
742
+ const elides = overflow === 'ellipsis' && cap < Infinity;
743
+ // A width offer of zero is yoga's min-content question (nodes/window/
744
+ // floors.js), which the bridge answered with a layout built unbounded —
745
+ // the whole paragraph on one line — so no `<text>` in a flex row could
746
+ // give way below its full length. Its `minWidth` is DirectWrite's own
747
+ // answer (`DetermineMinWidth`), and the paragraph laid out at it is the
748
+ // floor. An eliding line's floor is the mark alone, as the Cocoa engine
749
+ // has it (react-x11#512): it gives way, and says so with `…`.
750
+ if (Number.isFinite(maxWidth) && maxWidth <= 0) {
751
+ const open = { ...options, maxWidth: undefined };
752
+ if (elides && cap === 1 && sources.length) {
753
+ return this._laidOut([{ ...sources[0], text: ELLIPSIS }], base, open);
754
+ }
755
+ const wide = this._laidOut(sources, base, open);
756
+ const least = wide.minWidth;
757
+ if (!(least > 0) || least >= wide.width) return wide;
758
+ wide.destroy();
759
+ return this._laidOut(sources, base, { ...options, maxWidth: least });
760
+ }
761
+ let laid = sources;
762
+ let layout = this._laidOut(laid, base, options);
763
+ // With no width the bridge lays a paragraph out in a box a million
764
+ // pixels wide, and aligns it there: a centred line's `x` was half a
765
+ // million, an RTL one's a million, and <Html> measured a centred
766
+ // heading in a shrink-to-fit box as wide as all the room it had. ntk
767
+ // aligns the lines of an unbounded paragraph within its widest, and so
768
+ // does a paragraph laid out again at that width.
769
+ if (
770
+ !(maxWidth > 0 && Number.isFinite(maxWidth)) &&
771
+ layout.width > 0 &&
772
+ layout.lines.some((line) => line.x > 0.5)
773
+ ) {
774
+ const widest = layout.width;
775
+ layout.destroy();
776
+ layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
777
+ }
778
+ layout.truncated = layout.lines.length > cap;
779
+ if (layout.truncated) {
780
+ const last = layout.lines[cap - 1];
781
+ const text = layout._text;
782
+ layout.destroy();
783
+ let end = last.end;
784
+ if (elides) {
785
+ end = this._ellipsisAt(sources, base, options, text, last.start, end);
786
+ }
787
+ laid = spansTo(sources, end, elides ? ELLIPSIS : '');
788
+ layout = this._laidOut(laid, base, options);
789
+ // where the mark wrapped after all — a width rounded the other way
790
+ // than the probe's — the line gives up a character more, a few times
791
+ for (let i = 0; elides && layout.lines.length > cap && i < 4; i++) {
792
+ end = trimmedEnd(text, last.start, previousPoint(text, end));
793
+ laid = spansTo(sources, end, ELLIPSIS);
794
+ layout.destroy();
795
+ layout = this._laidOut(laid, base, options);
796
+ }
797
+ layout.truncated = true;
798
+ }
799
+ layout._hangRuns(sources, laid, this._runOf(base));
800
+ return layout;
801
+ }
802
+
803
+ /**
804
+ * Where the last line kept of an eliding paragraph ends: as far into
805
+ * `[start, end)` as fits beside the mark in `maxWidth`, white space before
806
+ * the mark left out. Measured as that line alone, unwrapped.
807
+ */
808
+ _ellipsisAt(sources, base, options, text, start, end) {
809
+ const room = options.maxWidth;
810
+ if (!(room > 0) || !Number.isFinite(room)) return end;
811
+ const open = { ...options, maxWidth: undefined, align: undefined };
812
+ const fits = (at) => {
813
+ const probe = this._laidOut(
814
+ spansBetween(sources, start, trimmedEnd(text, start, at), ELLIPSIS),
815
+ base,
816
+ open,
817
+ );
818
+ const width = probe.width;
819
+ probe.destroy();
820
+ return width <= room;
821
+ };
822
+ if (fits(end)) return trimmedEnd(text, start, end);
823
+ let lo = start;
824
+ let hi = end;
825
+ while (hi - lo > 1) {
826
+ const mid = pointAtOrBefore(text, (lo + hi) >> 1);
827
+ if (mid <= lo) break;
828
+ if (fits(mid)) lo = mid;
829
+ else hi = mid;
830
+ }
831
+ return trimmedEnd(text, start, lo);
832
+ }
833
+
834
+ /** What a run is set in — its face, size and direction, ntk's `run` — by
835
+ * the span it was laid out from, made once a span and direction. */
836
+ _runOf(base) {
837
+ const made = new Map();
838
+ return (span, rtl) => {
839
+ let byDirection = made.get(span);
840
+ if (!byDirection) {
841
+ byDirection = [null, null];
842
+ made.set(span, byDirection);
843
+ }
844
+ const at = rtl ? 1 : 0;
845
+ byDirection[at] ??= {
846
+ font: this.match(span.family ?? base.family, {
847
+ weight: span.weight ?? base.weight,
848
+ style: span.style ?? base.style,
849
+ }),
850
+ size: sizeOf(span.size ?? base.size),
851
+ direction: rtl ? 'rtl' : 'ltr',
852
+ };
853
+ return byDirection[at];
854
+ };
855
+ }
856
+
857
+ /** The bridge's layout of `list`, as it lays it out: no cap, no mark. */
858
+ _laidOut(list, base, options) {
436
859
  let text = '';
437
860
  const ranges = [];
438
861
  for (const span of list) {
@@ -441,32 +864,40 @@ export class Win32FontManager {
441
864
  const start = text.length;
442
865
  text += piece;
443
866
  const ink = colorOf(span.color ?? base.color);
867
+ const set = requestOf(
868
+ this._stack(span.family ?? base.family),
869
+ span.weight ?? base.weight,
870
+ span.style ?? base.style,
871
+ );
444
872
  ranges.push({
445
873
  start,
446
874
  length: piece.length,
447
- family: familyOf(this._native, span.family ?? base.family),
875
+ family: set.family,
448
876
  size: sizeOf(span.size ?? base.size),
449
- weight: weightOf(span.weight ?? base.weight ?? 400),
450
- italic: (span.style ?? base.style) === 'italic',
877
+ weight: set.weight,
878
+ italic: set.italic,
879
+ stretch: set.stretch,
451
880
  // A variable face's axes. Passed per span as well as on the base,
452
881
  // because a span carries its own and a paragraph is one layout.
453
882
  variations: span.variations ?? base.variations ?? undefined,
883
+ ...spacingOf(span, base),
454
884
  ...(ink ? ink : {}),
455
885
  });
456
886
  }
457
887
 
888
+ const own = requestOf(this._stack(base.family), base.weight, base.style);
458
889
  const handle = this._native.layoutCreate(
459
890
  text,
460
891
  {
461
- family: familyOf(this._native, base.family),
892
+ family: own.family,
462
893
  size: sizeOf(base.size),
463
- weight: weightOf(base.weight ?? 400),
464
- italic: base.style === 'italic',
894
+ weight: own.weight,
895
+ italic: own.italic,
896
+ stretch: own.stretch,
465
897
  variations: base.variations ?? undefined,
466
898
  maxWidth: options.maxWidth,
467
899
  align: options.align,
468
900
  lineHeight: options.lineHeight,
469
- maxLines: options.maxLines,
470
901
  rtl: options.direction === 'rtl',
471
902
  },
472
903
  ranges,
@@ -475,7 +906,7 @@ export class Win32FontManager {
475
906
  const raw = this._native.layoutMetrics(handle);
476
907
  if (DEBUG) {
477
908
  // The family is in here because the one way this goes quietly wrong is
478
- // `familyOf` not recognising a name and answering `sans-serif`: the
909
+ // `resolveStack` not recognising a name and answering `sans-serif`: the
479
910
  // text still draws, in the wrong face, at the wrong metrics, and the
480
911
  // only tell is a width that does not match the face the app thinks it
481
912
  // asked for.
@@ -486,37 +917,83 @@ export class Win32FontManager {
486
917
  );
487
918
  }
488
919
 
489
- // Every engine answers a descent per line — a field and the edit menu
490
- // measure a line's ink as its ascent plus its descent — and DirectWrite
491
- // reports a baseline and a height per line rather than a descent.
492
- const lines = (raw.lines ?? []).map((line) => ({
493
- ...line,
494
- descent: Math.max(0, line.height - line.baseline),
495
- // `rangeBands` walks a line's runs to build a selection highlight, and
496
- // reads each one's direction off a nested `run` object — ntk's shape.
497
- // A line with no runs is not an empty line here, it is a crash:
498
- // `line.runs is not iterable`.
499
- runs: (line.runs ?? []).map((run) => ({
500
- ...run,
501
- run: { direction: run.rtl ? 'rtl' : 'ltr' },
502
- })),
503
- }));
504
- return new Win32TextLayout(this, handle, text, { ...raw, lines });
920
+ // Every engine answers an ascent and a descent per line — a field and the
921
+ // edit menu measure a line's ink as their sum — and the bridge measures
922
+ // both off the faces on the line. A line it measured nothing on (an
923
+ // empty one) takes them from its baseline. Worked out from the baseline
924
+ // always, as they were before the bridge said, a `lineHeight` put the
925
+ // descent below the line box: the bridge sets that baseline at 0.8 of
926
+ // the face's, and a line at `lineHeight: 1` read as shorter than its
927
+ // own strut, so <Html> laid every paragraph out a line at a time.
928
+ //
929
+ // And a baseline is ntk's: from the top of the layout. DirectWrite's is
930
+ // from the top of its line, so every line but the first was read as
931
+ // though its baseline were on the first — <Html> drew a link's
932
+ // underline on the line above the link.
933
+ //
934
+ // A line's `width` is ntk's too: without the white space it ends on,
935
+ // which its `advance` keeps. The bridge measures the line's runs, the
936
+ // space a wrap ended it on among them, so a line that fitted read as
937
+ // wider than its box: <Html> took each line beside a float for one that
938
+ // did not fit there and moved it below the float, a line at a time.
939
+ const lines = (raw.lines ?? []).map((line) => {
940
+ const measured =
941
+ line.ascent > 0 && Number.isFinite(line.descent) && line.descent >= 0;
942
+ return {
943
+ ...line,
944
+ width: inkWidth(this._native, handle, text, line),
945
+ advance: line.width,
946
+ baseline: line.y + line.baseline,
947
+ ascent: measured ? line.ascent : line.baseline,
948
+ descent: measured
949
+ ? line.descent
950
+ : Math.max(0, line.height - line.baseline),
951
+ // `rangeBands` walks a line's runs to build a selection highlight,
952
+ // and reads each one's direction off a nested `run` object — ntk's
953
+ // shape. A line with no runs is not an empty line here, it is a
954
+ // crash: `line.runs is not iterable`.
955
+ runs: (line.runs ?? []).map((run) => ({
956
+ ...run,
957
+ run: { direction: run.rtl ? 'rtl' : 'ltr' },
958
+ })),
959
+ };
960
+ });
961
+ // DirectWrite's width leaves out a no-break space a line ends on, as it
962
+ // leaves out a space, where ntk's counts it as the letter it is to a
963
+ // line: a lone one measured nothing, and <Html>, which spaces an inline
964
+ // box's edges with one, made every edge a space too wide. The lines'
965
+ // own widths keep it, in the whole pixels the bridge answers in.
966
+ let width = raw.width;
967
+ for (const line of lines) {
968
+ width = Math.max(width, Math.ceil(line.width - 1e-3));
969
+ }
970
+ return new Win32TextLayout(this, handle, text, { ...raw, width, lines });
505
971
  }
506
972
 
507
- match(family, options = {}) {
508
- // the stack resolved once: a named family is a DirectWrite lookup, and
509
- // every text layout asks
510
- this._families ??= new Map();
511
- let name = this._families.get(family);
512
- if (name === undefined) {
513
- name = familyOf(this._native, family);
514
- this._families.set(family, name);
973
+ /** A stack resolved once (`resolveStack`): a named family is a
974
+ * DirectWrite lookup, and every layout asks for every span. */
975
+ _stack(stack) {
976
+ this._stacks ??= new Map();
977
+ const key = stack ?? '';
978
+ let resolved = this._stacks.get(key);
979
+ if (resolved === undefined) {
980
+ resolved = resolveStack(this._native, stack);
981
+ this._stacks.set(key, resolved);
515
982
  }
516
- const key = `${name}|${weightOf(options.weight)}|${options.style ?? 'normal'}`;
983
+ return resolved;
984
+ }
985
+
986
+ match(family, options = {}) {
987
+ const set = requestOf(this._stack(family), options.weight, options.style);
988
+ const style = set.italic ? 'italic' : 'normal';
989
+ const key = `${set.family}|${set.weight}|${style}|${set.stretch}`;
517
990
  let face = this._faces.get(key);
518
991
  if (!face) {
519
- face = new Win32Face(this, name, options);
992
+ face = new Win32Face(this, set.family, {
993
+ weight: set.weight,
994
+ style,
995
+ stretch: set.stretch,
996
+ });
520
997
  this._faces.set(key, face);
521
998
  }
522
999
  return face;