react-x11 2.40.0 → 2.41.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.
@@ -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
+ }
82
128
  }
83
- return 'sans-serif';
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 };
139
+ }
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() {
@@ -138,20 +407,68 @@ class Win32TextLayout {
138
407
  * where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
139
408
  * opportunities itself. */
140
409
  get minWidth() {
410
+ // asked of the bridge the first time, where it left it out of the
411
+ // metrics: the line breaker over the whole text again, which a layout
412
+ // made to be drawn is never asked for
413
+ if (this._metrics.minWidth === undefined) {
414
+ this._metrics.minWidth =
415
+ this._handle != null && this._native.layoutMinWidth
416
+ ? (this._native.layoutMinWidth(this._handle) ?? 0)
417
+ : 0;
418
+ }
141
419
  return this._metrics.minWidth;
142
420
  }
143
421
 
422
+ /**
423
+ * Keep the first `cap` lines of what was laid out: they are all this
424
+ * layout reports, answers for and draws (`_keptEnd`, `_keptHeight`). A
425
+ * capped paragraph is laid out past its cap to find where the cap falls,
426
+ * and keeping that layout's first lines is the same picture as laying the
427
+ * kept text out again, for one native layout where it took two.
428
+ */
429
+ _keep(cap) {
430
+ const lines = this._metrics.lines.slice(0, cap);
431
+ const last = lines[lines.length - 1];
432
+ if (!last) return;
433
+ let width = 0;
434
+ for (const line of lines)
435
+ width = Math.max(width, Math.ceil(line.width - 1e-3));
436
+ this._metrics = {
437
+ ...this._metrics,
438
+ lines,
439
+ width,
440
+ height: Math.ceil(last.y + last.height),
441
+ minWidth: undefined,
442
+ };
443
+ this._keptEnd = last.end;
444
+ this._keptHeight = last.y + last.height;
445
+ }
446
+
144
447
  draw(ctx, x, y) {
448
+ if (this._keptHeight === undefined) {
449
+ ctx._drawLayout(this, x, y);
450
+ return;
451
+ }
452
+ // the lines past the cap are in the layout, and stay out of the picture
453
+ ctx.save();
454
+ ctx.beginPath();
455
+ ctx.rect(x - 1e4, y, 2e4 + this._metrics.width, this._keptHeight);
456
+ ctx.clip();
145
457
  ctx._drawLayout(this, x, y);
458
+ ctx.restore();
146
459
  }
147
460
 
148
461
  indexAt(x, y) {
149
462
  const unit = this._native.layoutIndexAt(this._handle, x, y);
150
- return this._cpOf(unit);
463
+ return this._cpOf(Math.min(unit, this._keptEnd ?? unit));
151
464
  }
152
465
 
153
466
  caretPosition(cp) {
154
- return this._native.layoutCaret(this._handle, this._cuOf(cp));
467
+ const unit = this._cuOf(cp);
468
+ return this._native.layoutCaret(
469
+ this._handle,
470
+ Math.min(unit, this._keptEnd ?? unit),
471
+ );
155
472
  }
156
473
 
157
474
  /**
@@ -198,12 +515,19 @@ class Win32TextLayout {
198
515
  /** A face, as far as a renderer that positions glyphs itself reads one.
199
516
  * Size is deferred the way ntk's is. */
200
517
  class Win32Face {
201
- constructor(manager, family, { weight = 400, style = 'normal' } = {}) {
518
+ constructor(
519
+ manager,
520
+ family,
521
+ { weight = 400, style = 'normal', stretch = 5 } = {},
522
+ ) {
202
523
  this._manager = manager;
203
524
  this.family = family;
204
525
  this.weight = weightOf(weight);
205
526
  this.style = style;
206
527
  this.italic = style === 'italic' || style === 'oblique';
528
+ // DirectWrite's width, 1–9 with 5 normal: a face such as Arial Narrow is
529
+ // reached through it (`resolveStack`)
530
+ this.stretch = stretch;
207
531
  }
208
532
 
209
533
  /**
@@ -221,7 +545,15 @@ class Win32Face {
221
545
  size,
222
546
  this.weight,
223
547
  this.italic,
548
+ this.stretch,
224
549
  );
550
+ // ntk's `lineHeight` beside DirectWrite's names, as the Cocoa engine
551
+ // gives CoreText's, so a caller written against ntk reads a finite
552
+ // line height: <Html>'s `line-height: normal` read `NaN` here, and a
553
+ // `NaN` in a block's height culled the whole document from the paint.
554
+ if (m && typeof m === 'object' && !Number.isFinite(m.lineHeight)) {
555
+ m = { ...m, lineHeight: m.ascent + m.descent + (m.lineGap || 0) };
556
+ }
225
557
  this._metrics.set(size, m);
226
558
  }
227
559
  return m;
@@ -283,6 +615,20 @@ export class Win32FontManager {
283
615
  // family|weight|italic|size -> the bridge's handle, or null when there is
284
616
  // no such face
285
617
  this._handles = new Map();
618
+ // what this bridge's layouts take, asked once (`textFeatures`, from
619
+ // @windowkit/win32 0.0.9): an older one answers nothing and takes neither
620
+ this._features = native.textFeatures?.() ?? {};
621
+ }
622
+
623
+ /**
624
+ * Which of ntk's `justify` modes `layout` takes. DirectWrite justifies
625
+ * every line but a paragraph's last, which is `justify: true`, and has no
626
+ * way to justify a last line: `'rest'` says so, and a caller justifies the
627
+ * other two itself. <Html>, which spaced every justified line through
628
+ * letter spacing on this engine, lays a justified paragraph out once.
629
+ */
630
+ get justifies() {
631
+ return this._features.justify === true ? 'rest' : false;
286
632
  }
287
633
 
288
634
  /**
@@ -294,12 +640,18 @@ export class Win32FontManager {
294
640
  */
295
641
  _handleFor(face, size) {
296
642
  const px = Math.max(1, Math.round((size ?? 0) * 64)) / 64;
297
- const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${px}`;
643
+ const stretch = face.stretch ?? 5;
644
+ const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${stretch}|${px}`;
298
645
  let handle = this._handles.get(key);
299
646
  if (handle === undefined) {
300
647
  handle =
301
- this._native.fontHandle(face.family, px, face.weight, face.italic) ||
302
- null;
648
+ this._native.fontHandle(
649
+ face.family,
650
+ px,
651
+ face.weight,
652
+ face.italic,
653
+ stretch,
654
+ ) || null;
303
655
  this._handles.set(key, handle);
304
656
  }
305
657
  return handle;
@@ -370,9 +722,10 @@ export class Win32FontManager {
370
722
  }
371
723
  // Faces matched before this one existed were matched against a smaller
372
724
  // set of fonts, and one of them may have fallen back to what this
373
- // replaces.
725
+ // replaces — a stack resolved past its name among them.
374
726
  this._faces.clear();
375
727
  this._handles.clear();
728
+ this._stacks?.clear();
376
729
  return null;
377
730
  }
378
731
 
@@ -429,10 +782,185 @@ export class Win32FontManager {
429
782
  * layout rather than one per chunk — and therefore what makes line breaking
430
783
  * work *across* them, which is the whole reason the contract takes spans
431
784
  * rather than a string.
785
+ *
786
+ * The cap and the ellipsis are ntk's and are worked out here, since the
787
+ * bridge has neither: the paragraph is broken at the width, the lines past
788
+ * `maxLines` are dropped (`truncated` says whether any were), and an
789
+ * eliding one ends its last line in `…`, as much of the line kept as fits
790
+ * beside it. The bridge's own `maxLines` is never asked: it reads 1 as "do
791
+ * not wrap", so a one-line `<text>` came back as the whole paragraph on
792
+ * one line, as wide as all of it and with no ellipsis, and every paragraph
793
+ * a caller laid out a line at a time through it overran its box.
432
794
  */
433
795
  layout(spans, base = {}, options = {}) {
434
796
  const list =
435
797
  typeof spans === 'string' ? [{ text: spans, ...base }] : (spans ?? []);
798
+ // the caller's spans with text, which the runs hang off (`_hangRuns`)
799
+ const sources = list.filter((span) => String(span?.text ?? ''));
800
+ const { maxWidth, maxLines, overflow } = options;
801
+ const cap = Number.isFinite(maxLines)
802
+ ? Math.max(1, Math.floor(maxLines))
803
+ : Infinity;
804
+ const elides = overflow === 'ellipsis' && cap < Infinity;
805
+ // A width offer of zero is yoga's min-content question (nodes/window/
806
+ // floors.js), which the bridge answered with a layout built unbounded —
807
+ // the whole paragraph on one line — so no `<text>` in a flex row could
808
+ // give way below its full length. Its `minWidth` is DirectWrite's own
809
+ // answer (`DetermineMinWidth`), and the paragraph laid out at it is the
810
+ // floor. An eliding line's floor is the mark alone, as the Cocoa engine
811
+ // has it (react-x11#512): it gives way, and says so with `…`.
812
+ if (Number.isFinite(maxWidth) && maxWidth <= 0) {
813
+ const open = { ...options, maxWidth: undefined };
814
+ if (elides && cap === 1 && sources.length) {
815
+ return this._laidOut([{ ...sources[0], text: ELLIPSIS }], base, open);
816
+ }
817
+ const wide = this._laidOut(sources, base, open);
818
+ const least = wide.minWidth;
819
+ if (!(least > 0) || least >= wide.width) return wide;
820
+ wide.destroy();
821
+ return this._laidOut(sources, base, { ...options, maxWidth: least });
822
+ }
823
+ let laid = sources;
824
+ let layout =
825
+ this._prefixLaidOut(sources, base, options, cap) ??
826
+ this._laidOut(laid, base, options);
827
+ // With no width the bridge lays a paragraph out in a box a million
828
+ // pixels wide, and aligns it there: a centred line's `x` was half a
829
+ // million, an RTL one's a million, and <Html> measured a centred
830
+ // heading in a shrink-to-fit box as wide as all the room it had. ntk
831
+ // aligns the lines of an unbounded paragraph within its widest, and so
832
+ // does a paragraph laid out again at that width.
833
+ if (
834
+ !(maxWidth > 0 && Number.isFinite(maxWidth)) &&
835
+ layout.width > 0 &&
836
+ layout.lines.some((line) => line.x > 0.5)
837
+ ) {
838
+ const widest = layout.width;
839
+ layout.destroy();
840
+ layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
841
+ }
842
+ layout.truncated = layout.lines.length > cap;
843
+ if (layout.truncated && !elides) {
844
+ // what was laid out, a prefix or the whole, keeps its first lines
845
+ laid = spansTo(sources, layout._text.length);
846
+ layout._keep(cap);
847
+ } else if (layout.truncated) {
848
+ const last = layout.lines[cap - 1];
849
+ const text = layout._text;
850
+ layout.destroy();
851
+ let end = last.end;
852
+ if (elides) {
853
+ end = this._ellipsisAt(sources, base, options, text, last.start, end);
854
+ }
855
+ laid = spansTo(sources, end, elides ? ELLIPSIS : '');
856
+ layout = this._laidOut(laid, base, options);
857
+ // where the mark wrapped after all — a width rounded the other way
858
+ // than the probe's — the line gives up a character more, a few times
859
+ for (let i = 0; elides && layout.lines.length > cap && i < 4; i++) {
860
+ end = trimmedEnd(text, last.start, previousPoint(text, end));
861
+ laid = spansTo(sources, end, ELLIPSIS);
862
+ layout.destroy();
863
+ layout = this._laidOut(laid, base, options);
864
+ }
865
+ layout.truncated = true;
866
+ }
867
+ layout._hangRuns(sources, laid, this._runOf(base));
868
+ return layout;
869
+ }
870
+
871
+ /**
872
+ * A capped paragraph laid out only as far as the cap needs: a prefix of
873
+ * its text that breaks into more lines than `cap`, or null where the
874
+ * whole text is no longer than a prefix would be. The first lines of a
875
+ * prefix are the whole text's first lines wherever it reaches past them —
876
+ * a line's break waits only on the word after it, which then starts a
877
+ * line of the prefix — and the prefix ends at a break, its last word
878
+ * whole. <Html> lays a paragraph beside a float out a line at a time,
879
+ * each `maxLines: 1` over the rest of the paragraph; laid out whole, each
880
+ * line cost the whole rest of it, twice, and a paragraph cost the square
881
+ * of its length.
882
+ */
883
+ _prefixLaidOut(sources, base, options, cap) {
884
+ const { maxWidth } = options;
885
+ if (!(cap < Infinity) || !(maxWidth > 0) || !Number.isFinite(maxWidth)) {
886
+ return null;
887
+ }
888
+ const text = sources.map((span) => String(span.text)).join('');
889
+ let size = Infinity;
890
+ for (const span of sources)
891
+ size = Math.min(size, sizeOf(span.size ?? base.size));
892
+ // a third of an em a character is narrower than nearly any text is set
893
+ // in, so a prefix that long nearly always reaches past the lines wanted;
894
+ // where it does not, one twice as long is tried
895
+ let take = Math.ceil(maxWidth / (Math.max(1, size) * 0.3)) * cap + 16;
896
+ while (take < text.length) {
897
+ let end = take;
898
+ while (end < text.length && !BREAKING_SPACE.test(text[end])) end++;
899
+ if (end >= text.length) return null;
900
+ const layout = this._laidOut(spansTo(sources, end), base, options);
901
+ if (layout.lines.length > cap) return layout;
902
+ layout.destroy();
903
+ take *= 2;
904
+ }
905
+ return null;
906
+ }
907
+
908
+ /**
909
+ * Where the last line kept of an eliding paragraph ends: as far into
910
+ * `[start, end)` as fits beside the mark in `maxWidth`, white space before
911
+ * the mark left out. Measured as that line alone, unwrapped.
912
+ */
913
+ _ellipsisAt(sources, base, options, text, start, end) {
914
+ const room = options.maxWidth;
915
+ if (!(room > 0) || !Number.isFinite(room)) return end;
916
+ const open = { ...options, maxWidth: undefined, align: undefined };
917
+ const fits = (at) => {
918
+ const probe = this._laidOut(
919
+ spansBetween(sources, start, trimmedEnd(text, start, at), ELLIPSIS),
920
+ base,
921
+ open,
922
+ );
923
+ const width = probe.width;
924
+ probe.destroy();
925
+ return width <= room;
926
+ };
927
+ if (fits(end)) return trimmedEnd(text, start, end);
928
+ let lo = start;
929
+ let hi = end;
930
+ while (hi - lo > 1) {
931
+ const mid = pointAtOrBefore(text, (lo + hi) >> 1);
932
+ if (mid <= lo) break;
933
+ if (fits(mid)) lo = mid;
934
+ else hi = mid;
935
+ }
936
+ return trimmedEnd(text, start, lo);
937
+ }
938
+
939
+ /** What a run is set in — its face, size and direction, ntk's `run` — by
940
+ * the span it was laid out from, made once a span and direction. */
941
+ _runOf(base) {
942
+ const made = new Map();
943
+ return (span, rtl) => {
944
+ let byDirection = made.get(span);
945
+ if (!byDirection) {
946
+ byDirection = [null, null];
947
+ made.set(span, byDirection);
948
+ }
949
+ const at = rtl ? 1 : 0;
950
+ byDirection[at] ??= {
951
+ font: this.match(span.family ?? base.family, {
952
+ weight: span.weight ?? base.weight,
953
+ style: span.style ?? base.style,
954
+ }),
955
+ size: sizeOf(span.size ?? base.size),
956
+ direction: rtl ? 'rtl' : 'ltr',
957
+ };
958
+ return byDirection[at];
959
+ };
960
+ }
961
+
962
+ /** The bridge's layout of `list`, as it lays it out: no cap, no mark. */
963
+ _laidOut(list, base, options) {
436
964
  let text = '';
437
965
  const ranges = [];
438
966
  for (const span of list) {
@@ -441,41 +969,55 @@ export class Win32FontManager {
441
969
  const start = text.length;
442
970
  text += piece;
443
971
  const ink = colorOf(span.color ?? base.color);
972
+ const set = requestOf(
973
+ this._stack(span.family ?? base.family),
974
+ span.weight ?? base.weight,
975
+ span.style ?? base.style,
976
+ );
444
977
  ranges.push({
445
978
  start,
446
979
  length: piece.length,
447
- family: familyOf(this._native, span.family ?? base.family),
980
+ family: set.family,
448
981
  size: sizeOf(span.size ?? base.size),
449
- weight: weightOf(span.weight ?? base.weight ?? 400),
450
- italic: (span.style ?? base.style) === 'italic',
982
+ weight: set.weight,
983
+ italic: set.italic,
984
+ stretch: set.stretch,
451
985
  // A variable face's axes. Passed per span as well as on the base,
452
986
  // because a span carries its own and a paragraph is one layout.
453
987
  variations: span.variations ?? base.variations ?? undefined,
988
+ ...spacingOf(span, base),
454
989
  ...(ink ? ink : {}),
455
990
  });
456
991
  }
457
992
 
993
+ const own = requestOf(this._stack(base.family), base.weight, base.style);
458
994
  const handle = this._native.layoutCreate(
459
995
  text,
460
996
  {
461
- family: familyOf(this._native, base.family),
997
+ family: own.family,
462
998
  size: sizeOf(base.size),
463
- weight: weightOf(base.weight ?? 400),
464
- italic: base.style === 'italic',
999
+ weight: own.weight,
1000
+ italic: own.italic,
1001
+ stretch: own.stretch,
465
1002
  variations: base.variations ?? undefined,
466
1003
  maxWidth: options.maxWidth,
467
1004
  align: options.align,
468
1005
  lineHeight: options.lineHeight,
469
- maxLines: options.maxLines,
470
1006
  rtl: options.direction === 'rtl',
1007
+ // the one mode DirectWrite has (`justifies`); 'last' and 'all' are
1008
+ // the caller's
1009
+ justify: options.justify === true || undefined,
471
1010
  },
472
1011
  ranges,
473
1012
  );
474
1013
 
475
- const raw = this._native.layoutMetrics(handle);
1014
+ const raw = this._native.layoutMetrics(
1015
+ handle,
1016
+ this._features.lazyMinWidth ? { minWidth: false } : undefined,
1017
+ );
476
1018
  if (DEBUG) {
477
1019
  // 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
1020
+ // `resolveStack` not recognising a name and answering `sans-serif`: the
479
1021
  // text still draws, in the wrong face, at the wrong metrics, and the
480
1022
  // only tell is a width that does not match the face the app thinks it
481
1023
  // asked for.
@@ -486,37 +1028,83 @@ export class Win32FontManager {
486
1028
  );
487
1029
  }
488
1030
 
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 });
1031
+ // Every engine answers an ascent and a descent per line — a field and the
1032
+ // edit menu measure a line's ink as their sum — and the bridge measures
1033
+ // both off the faces on the line. A line it measured nothing on (an
1034
+ // empty one) takes them from its baseline. Worked out from the baseline
1035
+ // always, as they were before the bridge said, a `lineHeight` put the
1036
+ // descent below the line box: the bridge sets that baseline at 0.8 of
1037
+ // the face's, and a line at `lineHeight: 1` read as shorter than its
1038
+ // own strut, so <Html> laid every paragraph out a line at a time.
1039
+ //
1040
+ // And a baseline is ntk's: from the top of the layout. DirectWrite's is
1041
+ // from the top of its line, so every line but the first was read as
1042
+ // though its baseline were on the first — <Html> drew a link's
1043
+ // underline on the line above the link.
1044
+ //
1045
+ // A line's `width` is ntk's too: without the white space it ends on,
1046
+ // which its `advance` keeps. The bridge measures the line's runs, the
1047
+ // space a wrap ended it on among them, so a line that fitted read as
1048
+ // wider than its box: <Html> took each line beside a float for one that
1049
+ // did not fit there and moved it below the float, a line at a time.
1050
+ const lines = (raw.lines ?? []).map((line) => {
1051
+ const measured =
1052
+ line.ascent > 0 && Number.isFinite(line.descent) && line.descent >= 0;
1053
+ return {
1054
+ ...line,
1055
+ width: inkWidth(this._native, handle, text, line),
1056
+ advance: line.width,
1057
+ baseline: line.y + line.baseline,
1058
+ ascent: measured ? line.ascent : line.baseline,
1059
+ descent: measured
1060
+ ? line.descent
1061
+ : Math.max(0, line.height - line.baseline),
1062
+ // `rangeBands` walks a line's runs to build a selection highlight,
1063
+ // and reads each one's direction off a nested `run` object — ntk's
1064
+ // shape. A line with no runs is not an empty line here, it is a
1065
+ // crash: `line.runs is not iterable`.
1066
+ runs: (line.runs ?? []).map((run) => ({
1067
+ ...run,
1068
+ run: { direction: run.rtl ? 'rtl' : 'ltr' },
1069
+ })),
1070
+ };
1071
+ });
1072
+ // DirectWrite's width leaves out a no-break space a line ends on, as it
1073
+ // leaves out a space, where ntk's counts it as the letter it is to a
1074
+ // line: a lone one measured nothing, and <Html>, which spaces an inline
1075
+ // box's edges with one, made every edge a space too wide. The lines'
1076
+ // own widths keep it, in the whole pixels the bridge answers in.
1077
+ let width = raw.width;
1078
+ for (const line of lines) {
1079
+ width = Math.max(width, Math.ceil(line.width - 1e-3));
1080
+ }
1081
+ return new Win32TextLayout(this, handle, text, { ...raw, width, lines });
1082
+ }
1083
+
1084
+ /** A stack resolved once (`resolveStack`): a named family is a
1085
+ * DirectWrite lookup, and every layout asks for every span. */
1086
+ _stack(stack) {
1087
+ this._stacks ??= new Map();
1088
+ const key = stack ?? '';
1089
+ let resolved = this._stacks.get(key);
1090
+ if (resolved === undefined) {
1091
+ resolved = resolveStack(this._native, stack);
1092
+ this._stacks.set(key, resolved);
1093
+ }
1094
+ return resolved;
505
1095
  }
506
1096
 
507
1097
  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);
515
- }
516
- const key = `${name}|${weightOf(options.weight)}|${options.style ?? 'normal'}`;
1098
+ const set = requestOf(this._stack(family), options.weight, options.style);
1099
+ const style = set.italic ? 'italic' : 'normal';
1100
+ const key = `${set.family}|${set.weight}|${style}|${set.stretch}`;
517
1101
  let face = this._faces.get(key);
518
1102
  if (!face) {
519
- face = new Win32Face(this, name, options);
1103
+ face = new Win32Face(this, set.family, {
1104
+ weight: set.weight,
1105
+ style,
1106
+ stretch: set.stretch,
1107
+ });
520
1108
  this._faces.set(key, face);
521
1109
  }
522
1110
  return face;