@react-x11/components 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/code/index.d.ts.map +1 -1
  2. package/dist/code/index.js +25 -24
  3. package/dist/code/index.js.map +1 -1
  4. package/dist/code-language/index.d.ts +1 -1
  5. package/dist/code-language/index.d.ts.map +1 -1
  6. package/dist/code-language/index.js +1 -1
  7. package/dist/code-language/index.js.map +1 -1
  8. package/dist/code-language/runs.d.ts +31 -0
  9. package/dist/code-language/runs.d.ts.map +1 -1
  10. package/dist/code-language/runs.js +105 -0
  11. package/dist/code-language/runs.js.map +1 -1
  12. package/dist/codeblock/index.d.ts +13 -0
  13. package/dist/codeblock/index.d.ts.map +1 -1
  14. package/dist/codeblock/index.js +37 -1
  15. package/dist/codeblock/index.js.map +1 -1
  16. package/dist/flow/node.d.ts +14 -6
  17. package/dist/flow/node.d.ts.map +1 -1
  18. package/dist/flow/node.js +82 -64
  19. package/dist/flow/node.js.map +1 -1
  20. package/dist/html/controls.d.ts +26 -7
  21. package/dist/html/controls.d.ts.map +1 -1
  22. package/dist/html/controls.js +28 -3
  23. package/dist/html/controls.js.map +1 -1
  24. package/dist/html/css/cascade.d.ts +32 -10
  25. package/dist/html/css/cascade.d.ts.map +1 -1
  26. package/dist/html/css/cascade.js +89 -31
  27. package/dist/html/css/cascade.js.map +1 -1
  28. package/dist/html/css/color.d.ts +19 -0
  29. package/dist/html/css/color.d.ts.map +1 -1
  30. package/dist/html/css/color.js +86 -0
  31. package/dist/html/css/color.js.map +1 -1
  32. package/dist/html/css/parse.d.ts +11 -0
  33. package/dist/html/css/parse.d.ts.map +1 -1
  34. package/dist/html/css/parse.js +114 -4
  35. package/dist/html/css/parse.js.map +1 -1
  36. package/dist/html/css/style.d.ts +45 -5
  37. package/dist/html/css/style.d.ts.map +1 -1
  38. package/dist/html/css/style.js +363 -14
  39. package/dist/html/css/style.js.map +1 -1
  40. package/dist/html/css/ua.js +22 -5
  41. package/dist/html/css/ua.js.map +1 -1
  42. package/dist/html/css/values.d.ts +10 -1
  43. package/dist/html/css/values.d.ts.map +1 -1
  44. package/dist/html/css/values.js +27 -2
  45. package/dist/html/css/values.js.map +1 -1
  46. package/dist/html/css/vars.d.ts +5 -0
  47. package/dist/html/css/vars.d.ts.map +1 -1
  48. package/dist/html/css/vars.js +2 -2
  49. package/dist/html/css/vars.js.map +1 -1
  50. package/dist/html/dom.d.ts.map +1 -1
  51. package/dist/html/dom.js +35 -2
  52. package/dist/html/dom.js.map +1 -1
  53. package/dist/html/index.d.ts.map +1 -1
  54. package/dist/html/index.js +38 -1
  55. package/dist/html/index.js.map +1 -1
  56. package/dist/html/layout/block.d.ts +11 -2
  57. package/dist/html/layout/block.d.ts.map +1 -1
  58. package/dist/html/layout/block.js +306 -25
  59. package/dist/html/layout/block.js.map +1 -1
  60. package/dist/html/layout/boxes.d.ts +61 -6
  61. package/dist/html/layout/boxes.d.ts.map +1 -1
  62. package/dist/html/layout/boxes.js +65 -16
  63. package/dist/html/layout/boxes.js.map +1 -1
  64. package/dist/html/layout/cache.d.ts +2 -2
  65. package/dist/html/layout/cache.d.ts.map +1 -1
  66. package/dist/html/layout/cache.js +8 -3
  67. package/dist/html/layout/cache.js.map +1 -1
  68. package/dist/html/layout/floats.d.ts +4 -0
  69. package/dist/html/layout/floats.d.ts.map +1 -1
  70. package/dist/html/layout/floats.js +26 -0
  71. package/dist/html/layout/floats.js.map +1 -1
  72. package/dist/html/layout/inline.d.ts +29 -2
  73. package/dist/html/layout/inline.d.ts.map +1 -1
  74. package/dist/html/layout/inline.js +1454 -125
  75. package/dist/html/layout/inline.js.map +1 -1
  76. package/dist/html/node.d.ts +33 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +608 -86
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +53 -1
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +746 -214
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/surfaces.d.ts +7 -2
  85. package/dist/html/surfaces.d.ts.map +1 -1
  86. package/dist/html/surfaces.js +16 -0
  87. package/dist/html/surfaces.js.map +1 -1
  88. package/dist/html/svg.d.ts.map +1 -1
  89. package/dist/html/svg.js +27 -20
  90. package/dist/html/svg.js.map +1 -1
  91. package/dist/internal/codelines.d.ts +42 -0
  92. package/dist/internal/codelines.d.ts.map +1 -0
  93. package/dist/internal/codelines.js +170 -0
  94. package/dist/internal/codelines.js.map +1 -0
  95. package/dist/internal/window.d.ts +22 -0
  96. package/dist/internal/window.d.ts.map +1 -1
  97. package/dist/internal/window.js +35 -8
  98. package/dist/internal/window.js.map +1 -1
  99. package/dist/markdown/index.d.ts.map +1 -1
  100. package/dist/markdown/index.js +98 -26
  101. package/dist/markdown/index.js.map +1 -1
  102. package/dist/rich-text-editor/index.d.ts.map +1 -1
  103. package/dist/rich-text-editor/index.js +3 -8
  104. package/dist/rich-text-editor/index.js.map +1 -1
  105. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  106. package/dist/rich-text-editor/markdown.js +30 -1
  107. package/dist/rich-text-editor/markdown.js.map +1 -1
  108. package/dist/rich-text-editor/replace.d.ts +13 -0
  109. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  110. package/dist/rich-text-editor/replace.js +64 -0
  111. package/dist/rich-text-editor/replace.js.map +1 -0
  112. package/dist/richtext/node.d.ts +7 -0
  113. package/dist/richtext/node.d.ts.map +1 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/terminal-output/index.d.ts.map +1 -1
  116. package/dist/terminal-output/index.js +100 -25
  117. package/dist/terminal-output/index.js.map +1 -1
  118. package/dist/terminal-output/runs.d.ts +4 -1
  119. package/dist/terminal-output/runs.d.ts.map +1 -1
  120. package/dist/terminal-output/runs.js +5 -1
  121. package/dist/terminal-output/runs.js.map +1 -1
  122. package/package.json +5 -3
  123. package/src/code/index.ts +34 -32
  124. package/src/code-language/index.ts +1 -1
  125. package/src/code-language/runs.ts +111 -1
  126. package/src/codeblock/index.ts +48 -1
  127. package/src/flow/node.ts +80 -64
  128. package/src/html/controls.ts +50 -9
  129. package/src/html/css/cascade.ts +134 -43
  130. package/src/html/css/color.ts +87 -0
  131. package/src/html/css/parse.ts +107 -4
  132. package/src/html/css/style.ts +386 -23
  133. package/src/html/css/ua.ts +22 -5
  134. package/src/html/css/values.ts +31 -3
  135. package/src/html/css/vars.ts +2 -2
  136. package/src/html/dom.ts +38 -2
  137. package/src/html/index.ts +39 -1
  138. package/src/html/layout/block.ts +360 -22
  139. package/src/html/layout/boxes.ts +98 -23
  140. package/src/html/layout/cache.ts +8 -3
  141. package/src/html/layout/floats.ts +24 -0
  142. package/src/html/layout/inline.ts +1690 -119
  143. package/src/html/node.ts +648 -101
  144. package/src/html/paint.ts +883 -283
  145. package/src/html/surfaces.ts +21 -1
  146. package/src/html/svg.ts +30 -19
  147. package/src/internal/codelines.ts +200 -0
  148. package/src/internal/window.ts +36 -8
  149. package/src/markdown/index.ts +145 -26
  150. package/src/rich-text-editor/index.ts +3 -11
  151. package/src/rich-text-editor/markdown.ts +36 -1
  152. package/src/rich-text-editor/replace.ts +85 -0
  153. package/src/richtext/node.ts +7 -0
  154. package/src/terminal-output/index.ts +127 -34
  155. package/src/terminal-output/runs.ts +16 -2
@@ -29,11 +29,22 @@
29
29
  // *not* carry is its element: a layout outlives the parse it was made for
30
30
  // (`TextLayoutCache`), so the element under a run is found from its text's
31
31
  // place in the document (`LineText.spans`) instead.
32
+ import bidiModule from 'bidi-js';
32
33
  import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
33
34
  import { CONTAIN_LAYOUT, INLINE_BEFORE_ABSOLUTE, scrolls, } from '../css/style.js';
34
35
  import { inkColor, isTransparent, resolve } from '../css/values.js';
35
- import { BOX_RAISES, LINE_BOX_RAISES, isOffset, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, } from './boxes.js';
36
+ import { BOX_RAISES, LINE_BOX_RAISES, PADDED_FACES, isOffset, letteredAfter, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, transformInPlace, } from './boxes.js';
36
37
  import { tableGrid } from './grid.js';
38
+ /** How a paragraph's lines are fitted to their width, and measured: as a
39
+ * browser fits them (`FontsLike`'s `fit`). */
40
+ const PARAGRAPH_FIT = 'items';
41
+ /** How far past the room a line's content may reach and still fit it: a
42
+ * 64th of a pixel, the epsilon Blink's line breaker adds to the width it
43
+ * fits to (`LineBreaker::AvailableWidthToFit`), and the one the engine
44
+ * fits a paragraph's lines with. A line composed a piece at a time asks
45
+ * the same question of each piece, and half a pixel let a word run a
46
+ * third of one past the line a browser breaks before it. */
47
+ const FIT_SLACK = 1 / 64;
37
48
  /**
38
49
  * The text behind a layout, for the one caller that needs it: a selection
39
50
  * band has to turn a document offset into a **code point** index, which is
@@ -46,6 +57,20 @@ import { tableGrid } from './grid.js';
46
57
  */
47
58
  const LAYOUT_RUNS = new WeakMap();
48
59
  const WIDEST_WORD = new WeakMap();
60
+ /**
61
+ * A fragment of text that runs on from the text before it on its line, with
62
+ * no place to break between the two — only inline boxes' edges, or a float
63
+ * — and how wide those edges are: a word across a `<span>` with padding is
64
+ * in two fragments, and as wide as both and the padding between. What
65
+ * `joinsBefore` answers, for a min-content bound (`block.ts`'s `wordBound`)
66
+ * that would have the word no wider than either fragment's widest.
67
+ */
68
+ const JOINS = new WeakMap();
69
+ /** The edges a fragment of text runs on across from the text before it on
70
+ * its line, or undefined where it does not run on from it (`JOINS`). */
71
+ export function joinsBefore(text) {
72
+ return JOINS.get(text);
73
+ }
49
74
  /**
50
75
  * The widest word between spaces in a layout's text: its runs laid out
51
76
  * again with every space a line break, at no width limit. Where a text's
@@ -68,7 +93,10 @@ export function widestWord(fonts, layout, measured = true) {
68
93
  const words = fonts.layout(held.map((run) => ({
69
94
  ...run,
70
95
  text: run.text.replace(/[ \t\u200b]+/g, '\n'),
71
- })), {}, {});
96
+ })), {},
97
+ // measured as the lines are fitted, or a word across two elements is
98
+ // read narrower than it fits
99
+ { fit: PARAGRAPH_FIT });
72
100
  widest = 0;
73
101
  for (const line of words.lines)
74
102
  widest = Math.max(widest, line.width);
@@ -246,6 +274,8 @@ function linesOf(block, options, items, floatCount) {
246
274
  CLIPPED_TEXT.add(block);
247
275
  fonts = recording(fonts);
248
276
  }
277
+ // small capitals the face does not have, made of its capitals
278
+ synthesizeSmallCaps(items, fonts);
249
279
  if (wraps(block.style))
250
280
  holdNoWrap(items, block);
251
281
  // one walk for what few paragraphs have: text that casts a shadow, and a
@@ -253,12 +283,17 @@ function linesOf(block, options, items, floatCount) {
253
283
  // searched for one where it cannot be
254
284
  let tabbed = false;
255
285
  let shadowed = false;
286
+ // and text that keeps its spaces where its lines wrap, whose spaces a
287
+ // line may end on (`hangPreserved`)
288
+ let keepsSpaces = false;
256
289
  for (const item of items) {
257
290
  if (item.kind !== 'text' || item.control)
258
291
  continue;
259
292
  const style = item.box.style;
260
293
  if (style.textShadow)
261
294
  shadowed = true;
295
+ if (style.whiteSpace === 'pre-wrap')
296
+ keepsSpaces = true;
262
297
  if (!tabbed &&
263
298
  (style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
264
299
  item.run.text.includes('\t')) {
@@ -293,6 +328,8 @@ function linesOf(block, options, items, floatCount) {
293
328
  firstLineColour(items, options.firstLine);
294
329
  const style = block.style;
295
330
  const wrapWords = overflowWrapOf(style, items);
331
+ // a line may break between any two letters, an edge between them or not
332
+ const breaksAnywhere = style.wordBreak === 'break-all' || style.lineBreakAnywhere;
296
333
  const base = {
297
334
  family: style.fontFamily,
298
335
  size: style.fontSize,
@@ -353,23 +390,33 @@ function linesOf(block, options, items, floatCount) {
353
390
  const spaced = hasEdges && !hasAtomics && !hasOffset && spacersHold(style, items);
354
391
  // a first line in fonts of its own is found a piece at a time, in them
355
392
  const restyled = options.firstLineStyle !== undefined;
393
+ // and where one layout makes a line shorter than the strut, its text all
394
+ // smaller than the block's, the lines are made a piece at a time too,
395
+ // where every line holds it (`shortOfStrut`)
396
+ const strut = fonts ? strutOf(fonts, style) : null;
397
+ // and the first line holds what it is handed besides: a marker's room
398
+ const firstStrut = withRoom(strut, options.firstStrut);
399
+ let strutted = false;
356
400
  // Inline boxes' edges as spacers in one layout, where the engine left
357
401
  // every edge on the line of the content it belongs to (`layoutSpaced`);
358
402
  // the lines a piece at a time, below, where it did not.
359
403
  if (spaced && !floated && !indent && !alignedApart && !restyled) {
360
- const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts);
361
- if (laid)
404
+ const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts, keepsSpaces);
405
+ if (laid && !shortOfStrut(laid.lines, strut, firstStrut))
362
406
  return laid;
407
+ if (laid)
408
+ strutted = true;
363
409
  }
364
410
  // The text-only, float-free, unindented case: one call, every line — or
365
411
  // one call per *chunk*, when the text is long and carries hard breaks.
366
- if (!hasAtomics &&
412
+ fast: if (!hasAtomics &&
367
413
  !hasEdges &&
368
414
  !raised &&
369
415
  !floated &&
370
416
  !indent &&
371
417
  !alignedApart &&
372
- !restyled) {
418
+ !restyled &&
419
+ !strutted) {
373
420
  // no atomics and no edges, so everything is text
374
421
  const textItems = items;
375
422
  let total = 0;
@@ -390,12 +437,16 @@ function linesOf(block, options, items, floatCount) {
390
437
  const perLine = cut !== null && !clamp && hasNewline;
391
438
  // an embedding cannot be cut into chunks
392
439
  if (perLine && !hasControls(items)) {
393
- return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut });
440
+ return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut }, keepsSpaces);
394
441
  }
395
442
  if (perLine)
396
443
  cut = null;
397
444
  if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
398
- return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts);
445
+ const chunked = layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, CHUNKS, keepsSpaces);
446
+ if (!shortOfStrut(chunked.lines, strut, firstStrut))
447
+ return chunked;
448
+ strutted = true;
449
+ break fast;
399
450
  }
400
451
  let runs = [];
401
452
  let spans = new SpanMap();
@@ -414,10 +465,19 @@ function linesOf(block, options, items, floatCount) {
414
465
  align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
415
466
  direction: style.direction,
416
467
  overflowWrap: cutWrap(style, cut, wrapWords),
468
+ fit: PARAGRAPH_FIT,
417
469
  ...cut,
418
470
  };
419
471
  let layout = fonts.layout(runs, base, layoutOptions);
420
472
  LAYOUT_RUNS.set(layout, runs);
473
+ const held = keepsSpaces
474
+ ? holdAtBreaks(layout, runs, spans, fonts, layoutOptions.maxWidth ?? options.width, !cut)
475
+ : null;
476
+ if (held) {
477
+ runs = held;
478
+ layout = fonts.layout(runs, base, layoutOptions);
479
+ LAYOUT_RUNS.set(layout, runs);
480
+ }
421
481
  // A clamp that cut the text short says so, for the clamp point after
422
482
  // it, and its last line ends in an ellipsis — as it does where the
423
483
  // clamp point falls just after the lines, more of the container after
@@ -464,11 +524,22 @@ function linesOf(block, options, items, floatCount) {
464
524
  : balanced
465
525
  ? unwrappedPlacer(style, options.width, false)
466
526
  : null);
527
+ if (keepsSpaces)
528
+ hangLines(lines, 0, runs, fonts, rtl);
529
+ // a clamp and a cut are the one layout's to make
530
+ if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
531
+ strutted = true;
532
+ break fast;
533
+ }
467
534
  return clampCut
468
535
  ? { lines, height: layout.height, width: widest, cut: true }
469
536
  : { lines, height: layout.height, width: widest };
470
537
  }
471
538
  // --- the general case: line at a time -------------------------------------
539
+ // the paragraph's bidi levels, where anything in it can be reordered: a
540
+ // line is laid out a piece at a time here, and its pieces are ordered by
541
+ // them (`levelPieces`)
542
+ const paraBidi = fonts ? paragraphBidi(block, items, rtl) : null;
472
543
  const lines = [];
473
544
  let widest = 0;
474
545
  let y = 0;
@@ -509,7 +580,6 @@ function linesOf(block, options, items, floatCount) {
509
580
  }
510
581
  deferred = [];
511
582
  };
512
- const strut = fonts ? strutOf(fonts, style) : null;
513
583
  // How tall a line is taken to be before it is made, for its room beside
514
584
  // the floats: the paragraph's strut, a line of its text. An item taller
515
585
  // asks over its own height, and the line made over its whole (`close`).
@@ -530,6 +600,7 @@ function linesOf(block, options, items, floatCount) {
530
600
  align: 'left',
531
601
  direction: style.direction,
532
602
  overflowWrap: wrapWords,
603
+ fit: PARAGRAPH_FIT,
533
604
  },
534
605
  }
535
606
  : null;
@@ -541,14 +612,24 @@ function linesOf(block, options, items, floatCount) {
541
612
  * which `text-align-last` aligns. */
542
613
  const close = (last = false) => {
543
614
  const onFirst = lineOne !== null && !lines.length;
615
+ const levels = paraBidi
616
+ ? {
617
+ bidi: paraBidi,
618
+ fonts: fonts,
619
+ base: onFirst ? lineOne.base : base,
620
+ lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
621
+ }
622
+ : null;
544
623
  const line = finishLine(open, y,
545
624
  // the room beside the floats over the whole line box, which an
546
625
  // inline-block can make taller than its text (CSS 2.1 9.5)
547
- (height) => bandAt(options, y, Math.max(height, guess)), last || !wraps(style) ? lastShift : restShift, rtl, onFirst ? lineOne.strut : strut, onFirst ? lineOne.lifts : lifts, (last || !wraps(style) ? justify.last : justify.rest)
626
+ (height) => bandAt(options, y, Math.max(height, guess)), last || !wraps(style) ? lastShift : restShift, rtl, lines.length
627
+ ? strut
628
+ : withRoom(onFirst ? lineOne.strut : strut, options.firstStrut), onFirst ? lineOne.lifts : lifts, (last || !wraps(style) ? justify.last : justify.rest)
548
629
  ? onFirst
549
630
  ? lineOne.justify
550
631
  : lineJustify
551
- : null);
632
+ : null, levels);
552
633
  if (!line) {
553
634
  open = openLine(0);
554
635
  placeDeferred();
@@ -668,7 +749,12 @@ function linesOf(block, options, items, floatCount) {
668
749
  if (raise)
669
750
  placed.raise = raise;
670
751
  open.atomics.push(placed);
671
- open.order.push({ kind: 'atomic', at: open.x, item: placed });
752
+ open.order.push({
753
+ kind: 'atomic',
754
+ at: open.x,
755
+ item: placed,
756
+ ...(paraBidi ? { para: paraBidi.starts[index] } : null),
757
+ });
672
758
  open.x += outer;
673
759
  open.hang = 0;
674
760
  open.left = room.left;
@@ -702,13 +788,17 @@ function linesOf(block, options, items, floatCount) {
702
788
  // `text-align-last` sets it apart)
703
789
  const tailIsPlain = !alignedApart &&
704
790
  !onFirst &&
791
+ !strutted &&
705
792
  segment.nextIndex >= items.length &&
706
793
  open.x === 0 &&
707
794
  !open.atomics.length &&
708
795
  !open.edges.length &&
709
796
  !pending.length &&
710
797
  !deferred.length &&
711
- !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
798
+ !(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
799
+ // and ordered alone as the paragraph orders it
800
+ (!paraBidi ||
801
+ orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
712
802
  if (tailIsPlain) {
713
803
  const tailOptions = {
714
804
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
@@ -716,42 +806,63 @@ function linesOf(block, options, items, floatCount) {
716
806
  align,
717
807
  direction: style.direction,
718
808
  overflowWrap: wrapWords,
809
+ fit: PARAGRAPH_FIT,
719
810
  };
720
- let layout = fonts.layout(segment.runs, base, tailOptions);
721
- LAYOUT_RUNS.set(layout, segment.runs);
811
+ let tailRuns = segment.runs;
812
+ let layout = fonts.layout(tailRuns, base, tailOptions);
813
+ LAYOUT_RUNS.set(layout, tailRuns);
814
+ const held = keepsSpaces
815
+ ? holdAtBreaks(layout, tailRuns, segment.spans, fonts, tailOptions.maxWidth ?? options.width, true)
816
+ : null;
817
+ if (held) {
818
+ tailRuns = held;
819
+ layout = fonts.layout(tailRuns, base, tailOptions);
820
+ LAYOUT_RUNS.set(layout, tailRuns);
821
+ }
722
822
  // the lines past the floats are justified as the lines beside them
723
823
  // were (`finishLine`), in the room they have
724
- layout = justifiedLayout(fonts, segment.runs, base, tailOptions, layout, Math.max(1, available), justify).layout;
824
+ layout = justifiedLayout(fonts, tailRuns, base, tailOptions, layout, Math.max(1, available), justify).layout;
825
+ const tail = [];
826
+ const lift = layoutLift(layout);
725
827
  for (let i = 0; i < layout.lines.length; i += 1) {
726
828
  const natural = layout.lines[i];
727
829
  const text = {
728
830
  layout,
729
831
  layoutLine: i,
730
832
  drawX: band.left,
731
- drawY: y,
833
+ drawY: y - lift,
732
834
  textStart: segment.spans.documentAt(natural.start),
733
835
  textEnd: segment.spans.documentAt(natural.end),
734
836
  layoutStart: natural.start,
735
837
  spans: segment.spans,
736
838
  };
737
839
  segment.spans.giveGaps(text, natural.start, natural.end);
738
- lines.push({
840
+ tail.push({
739
841
  x: band.left + natural.x,
740
842
  y: y + natural.y,
741
843
  width: natural.width,
742
844
  height: natural.height,
743
- baseline: natural.baseline - natural.y,
845
+ baseline: natural.baseline - natural.y - lift,
744
846
  texts: [text],
745
847
  textStart: text.textStart,
746
848
  textEnd: text.textEnd,
747
849
  atomics: [],
748
850
  });
749
- widest = Math.max(widest, natural.width);
750
851
  }
751
- y += layout.height;
752
- index = segment.nextIndex;
753
- offset = 0;
754
- continue;
852
+ if (keepsSpaces)
853
+ hangLines(tail, 0, tailRuns, fonts, rtl);
854
+ // or a line at a time, where one of them is shorter than the strut
855
+ if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
856
+ for (const line of tail) {
857
+ lines.push(line);
858
+ widest = Math.max(widest, line.width);
859
+ }
860
+ y += layout.height;
861
+ index = segment.nextIndex;
862
+ offset = 0;
863
+ continue;
864
+ }
865
+ strutted = true;
755
866
  }
756
867
  // One line: `maxLines: 1` cuts the layout at the first break and its
757
868
  // `truncated` flag answers "did the segment wrap" — so a line costs one
@@ -770,27 +881,72 @@ function linesOf(block, options, items, floatCount) {
770
881
  !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
771
882
  ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
772
883
  : 0;
773
- const fragment = fonts.layout(segment.runs, lineBase, {
774
- maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
775
- lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
776
- // Never aligned by the text layout: the alignment belongs to the whole
777
- // line — its text, its atomics and its inline boxes' edges together —
778
- // which only this loop can see, and `finishLine` shifts it all. Laid
779
- // out aligned, the first fragment of a centred line was centred alone
780
- // and whatever followed it on the line was placed as though it had
781
- // not been, over it.
782
- align: 'left',
783
- direction: style.direction,
784
- overflowWrap: wrapWords,
785
- maxLines: 1,
786
- });
787
- LAYOUT_RUNS.set(fragment, segment.runs);
788
- const first = fragment.lines[0];
884
+ const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
885
+ let fragment = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, room - tied);
886
+ let first = fragment.lines[0];
789
887
  if (!first) {
790
888
  index = segment.nextIndex;
791
889
  offset = 0;
792
890
  continue;
793
891
  }
892
+ // Did the segment wrap? ntk answers with `truncated`. A layout that does
893
+ // not carry the flag — react-x11's Cocoa engine reports none — is asked
894
+ // the same of its line ends instead: it wrapped if anything but
895
+ // whitespace follows the first line. Read as "fitted", a cut fragment
896
+ // advanced past the whole segment, and a paragraph beside a float lost
897
+ // every line after its first. And a line that ends at a `<br>` is over
898
+ // whether or not anything followed it in this segment: what comes next
899
+ // — an atomic, an element's edge — is the next line's.
900
+ let wrapped = breakBefore(segment.runs, first.end) ||
901
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
902
+ // An inline box's edge is no place to break a line (CSS Text 3, 5.1):
903
+ // where the segment runs to its end on this line, its last word runs on
904
+ // past it, through the edges after it, into the text they stand before
905
+ // — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
906
+ // without the padding. Where the line may break at that text after all,
907
+ // the edges that close boxes are still the word's, as a box's end goes
908
+ // with the content it closes, and it is the ones that open a box that
909
+ // go with what follows. The line holds the word and all of that or none
910
+ // of it, so where it does not fit, the line breaks at the last place in
911
+ // the segment it may break, and the word goes to the next line whole.
912
+ // Where the segment has no such place, the word is the line's first,
913
+ // and is measured with what it runs into (below). A word that ends in
914
+ // white space hangs it, and its edges after it with it.
915
+ let glued = 0;
916
+ if (!wrapped &&
917
+ wraps(style) &&
918
+ lineItems[segment.nextIndex]?.kind === 'edge' &&
919
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
920
+ // none of it matters where the most it could come to fits: most
921
+ // edges are nowhere near a line's end
922
+ first.width + unbreakableBound(lineItems, segment.nextIndex) >
923
+ room + FIT_SLACK) {
924
+ const after = textAfterEdges(lineItems, segment.nextIndex);
925
+ glued =
926
+ after &&
927
+ !breaksBetween(fonts, lineBase, segment.runs, runsLength(segment.runs), after, breaksAnywhere)
928
+ ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
929
+ : closingEdges(lineItems, segment.nextIndex);
930
+ }
931
+ if (glued > 0 && first.width + glued > room + FIT_SLACK) {
932
+ // the last place it may break: laid out a hair narrower than it is,
933
+ // which the rest of the segment fits, not in the room the word
934
+ // leaves, where it broke after its first word if the word and what
935
+ // it runs into are wider than the line
936
+ const earlier = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, first.width - 0.5);
937
+ const line = earlier.lines[0];
938
+ // and not inside a word that `overflow-wrap` cut to fit: that is for
939
+ // a word no line holds, and the word and what it runs into may fit
940
+ // the next line
941
+ if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
942
+ fragment = earlier;
943
+ first = line;
944
+ wrapped = true;
945
+ glued = 0;
946
+ }
947
+ }
948
+ // the first word, with what it runs into past the segment
949
+ const word = first.width + glued;
794
950
  // Nothing fits beside the floats — the first word is wider than the
795
951
  // room they leave, and was either run past it or cut inside itself to
796
952
  // fit — so the line moves down to where a float ends, and tries again
@@ -799,7 +955,7 @@ function linesOf(block, options, items, floatCount) {
799
955
  // is `overflow-wrap`'s to break or to let run past.
800
956
  if (isEmpty(open) &&
801
957
  available < options.width &&
802
- tooNarrow(segment.runs, first.end, first.width, room)) {
958
+ tooNarrow(segment.runs, first.end, word, room)) {
803
959
  const below = belowFloats(options, y, guess);
804
960
  if (below !== null) {
805
961
  y = below;
@@ -808,27 +964,37 @@ function linesOf(block, options, items, floatCount) {
808
964
  }
809
965
  // An inline box's opening edge goes with the first word after it: where
810
966
  // the word does not fit the room the edge leaves, both go to the next
811
- // line, rather than the edge being left at this one's end. And a word
812
- // that may start a line — after a space the line ends on, or after an
813
- // atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
814
- // next line whole where it does not fit what is left of this one,
815
- // rather than being broken inside itself to fit it: the text after an
816
- // inline-block, or after a float it was cut at, was its first letter
817
- // at the line's end and the rest on the next.
967
+ // line, rather than the edge being left at this one's end — where the
968
+ // line may break there at all. The edge is no place to break (CSS Text
969
+ // 3, 5.1), so that is where the line may break between what it ends on
970
+ // and the word: after white space or an atomic, or where the text
971
+ // engine breaks the two run together (`breaksBetween`). `ab<span
972
+ // style="padding: 0 4px">cd</span>` is one word, and a line too narrow
973
+ // for it runs past its end, as it does for the word without the
974
+ // padding; broken before the span, a float's min-content width was a
975
+ // letter wide, and it came out as wide as its room.
976
+ // And a word that may start a line — after a space the line ends on,
977
+ // or after an atomic, which has a break after it (CSS Text 3, 5.1) —
978
+ // goes to the next line whole where it does not fit what is left of
979
+ // this one, rather than being broken inside itself to fit it: the text
980
+ // after an inline-block, or after a float it was cut at, was its first
981
+ // letter at the line's end and the rest on the next.
818
982
  const startsLine = wraps(style) &&
819
983
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
820
984
  if (!isEmpty(open) &&
821
985
  (pendingWidth > 0
822
- ? tooNarrow(segment.runs, first.end, first.width, room)
986
+ ? tooNarrow(segment.runs, first.end, word, room) &&
987
+ breaksAfterLine(open, style, fonts, lineBase, segment.runs, breaksAnywhere)
823
988
  : startsLine &&
824
989
  // run past the room — its white space is no part of the width —
825
990
  // or cut inside itself to fit it
826
- (first.width > room + 0.5 ||
827
- tooNarrow(segment.runs, first.end, first.width, room)))) {
991
+ (word > room + FIT_SLACK ||
992
+ tooNarrow(segment.runs, first.end, word, room)))) {
828
993
  close();
829
994
  continue;
830
995
  }
831
996
  placePending(band.left);
997
+ const joins = joinsOnto(open, segment.runs, first.start);
832
998
  const placed = {
833
999
  layout: fragment,
834
1000
  layoutLine: 0,
@@ -841,26 +1007,35 @@ function linesOf(block, options, items, floatCount) {
841
1007
  spans: segment.spans,
842
1008
  };
843
1009
  segment.spans.giveGaps(placed, first.start, first.end);
1010
+ if (joins !== undefined)
1011
+ JOINS.set(placed, joins);
844
1012
  open.texts.push(placed);
845
1013
  open.order.push({
846
1014
  kind: 'text',
847
1015
  at: open.x,
848
1016
  item: placed,
849
1017
  reads: readingOf(segment.runs, first.start, first.end),
1018
+ ...(paraBidi
1019
+ ? {
1020
+ from: {
1021
+ para: paraBidi.starts[index] + offset,
1022
+ runs: segment.runs,
1023
+ spans: segment.spans,
1024
+ },
1025
+ }
1026
+ : null),
850
1027
  });
851
1028
  open.x += first.width;
852
1029
  open.left = band.left;
853
1030
  open.hang = 0;
854
- // Did the segment wrap? ntk answers with `truncated`. A layout that does
855
- // not carry the flag — react-x11's Cocoa engine reports none — is asked
856
- // the same of its line ends instead: it wrapped if anything but
857
- // whitespace follows the first line. Read as "fitted", a cut fragment
858
- // advanced past the whole segment, and a paragraph beside a float lost
859
- // every line after its first. And a line that ends at a `<br>` is over
860
- // whether or not anything followed it in this segment: what comes next
861
- // — an atomic, an element's edge — is the next line's.
862
- const wrapped = breakBefore(segment.runs, first.end) ||
863
- (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1031
+ // the spaces `pre-wrap` keeps that it ends on, which the engine hung
1032
+ const hung = keepsSpaces
1033
+ ? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
1034
+ : NOTHING_HUNG;
1035
+ /** Before a forced break, the ones that fit take room (4.1.3). */
1036
+ const holdHung = () => {
1037
+ open.x += Math.min(hung.total, Math.max(0, available - open.x));
1038
+ };
864
1039
  if (!wrapped) {
865
1040
  // It fitted: the cursor stays on this line for whatever comes next.
866
1041
  //
@@ -874,7 +1049,11 @@ function linesOf(block, options, items, floatCount) {
874
1049
  const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
875
1050
  let trailing = '';
876
1051
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
877
- const text = segment.runs[i].text;
1052
+ // through the bidi controls after them, which take no room: the
1053
+ // engine strips the spaces before one as well
1054
+ const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
1055
+ if (!text)
1056
+ continue;
878
1057
  const m = hung.exec(text);
879
1058
  if (!m)
880
1059
  break;
@@ -884,18 +1063,29 @@ function linesOf(block, options, items, floatCount) {
884
1063
  }
885
1064
  if (trailing) {
886
1065
  // and noted: if what follows goes to the next line after all,
887
- // these spaces end this one, where CSS removes them (16.6.1)
1066
+ // these spaces end this one, where CSS removes them (16.6.1).
1067
+ // As wide as the engine says they make its line where the line
1068
+ // goes on, which a layout that fits as a browser does rounds with
1069
+ // the text they end, once (`advance`): the text rounded up to a
1070
+ // 64th and the spaces added after it came to more
888
1071
  open.hang =
889
- spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
890
- trailing.length;
1072
+ first.advance !== undefined
1073
+ ? first.advance - first.width
1074
+ : spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1075
+ trailing.length;
891
1076
  open.x += open.hang;
892
1077
  }
893
1078
  }
1079
+ else if (hung.total)
1080
+ holdHung();
894
1081
  index = segment.nextIndex;
895
1082
  offset = 0;
896
1083
  continue;
897
1084
  }
898
- close(breakBefore(segment.runs, first.end));
1085
+ const forced = breakBefore(segment.runs, first.end);
1086
+ if (forced && hung.total)
1087
+ holdHung();
1088
+ close(forced);
899
1089
  const advanced = advance(items, index, offset, first.end);
900
1090
  index = advanced.index;
901
1091
  offset = advanced.offset;
@@ -912,6 +1102,27 @@ function linesOf(block, options, items, floatCount) {
912
1102
  return { lines, height: y, width: widest, floatRow };
913
1103
  }
914
1104
  const EMPTY = { lines: [], height: 0, width: 0 };
1105
+ /** The first line of a segment's text in a width, as the line at a time
1106
+ * lays one out (`linesOf`). */
1107
+ function fragmentLayout(fonts, runs, base, style, lineHeight, overflowWrap, width) {
1108
+ const laid = fonts.layout(runs, base, {
1109
+ maxWidth: wraps(style) ? Math.max(1, width) : undefined,
1110
+ lineHeight,
1111
+ // Never aligned by the text layout: the alignment belongs to the whole
1112
+ // line — its text, its atomics and its inline boxes' edges together —
1113
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1114
+ // out aligned, the first fragment of a centred line was centred alone
1115
+ // and whatever followed it on the line was placed as though it had not
1116
+ // been, over it.
1117
+ align: 'left',
1118
+ direction: style.direction,
1119
+ overflowWrap,
1120
+ fit: PARAGRAPH_FIT,
1121
+ maxLines: 1,
1122
+ });
1123
+ LAYOUT_RUNS.set(laid, runs);
1124
+ return laid;
1125
+ }
915
1126
  /**
916
1127
  * Which line each float a paragraph placed stands beside: the one whose top
917
1128
  * it went at, whose content moved over for it — added up on the line, for
@@ -1019,9 +1230,9 @@ function spacersHold(style, items) {
1019
1230
  * (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
1020
1231
  * keep it there.
1021
1232
  */
1022
- function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1233
+ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts, keepsSpaces) {
1023
1234
  const wrapWords = overflowWrapOf(style, items);
1024
- const runs = [];
1235
+ let runs = [];
1025
1236
  const spans = new SpanMap();
1026
1237
  const spacers = [];
1027
1238
  let doc = 0;
@@ -1050,12 +1261,22 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1050
1261
  align,
1051
1262
  direction: style.direction,
1052
1263
  overflowWrap: wrapWords,
1264
+ fit: PARAGRAPH_FIT,
1053
1265
  };
1054
1266
  let layout = fonts.layout(runs, base, layoutOptions);
1055
1267
  LAYOUT_RUNS.set(layout, runs);
1268
+ const held = keepsSpaces
1269
+ ? holdAtBreaks(layout, runs, spans, fonts, width, true)
1270
+ : null;
1271
+ if (held) {
1272
+ runs = held;
1273
+ layout = fonts.layout(runs, base, layoutOptions);
1274
+ LAYOUT_RUNS.set(layout, runs);
1275
+ }
1056
1276
  // justified with its spacers in it, which are edges and not spaces
1057
1277
  layout = justifiedLayout(fonts, runs, base, layoutOptions, layout, width, justification(style), new Set(spacers.map((spacer) => spacer.at))).layout;
1058
1278
  const place = wraps(style) ? null : unwrappedPlacer(style, width);
1279
+ const lift = layoutLift(layout);
1059
1280
  let offsets = null;
1060
1281
  const lines = [];
1061
1282
  let widest = 0;
@@ -1086,7 +1307,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1086
1307
  layout,
1087
1308
  layoutLine: i,
1088
1309
  drawX: dx,
1089
- drawY: 0,
1310
+ drawY: -lift,
1090
1311
  textStart: spans.documentAt(natural.start),
1091
1312
  textEnd: spans.documentAt(natural.end),
1092
1313
  layoutStart: natural.start,
@@ -1098,7 +1319,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1098
1319
  y: natural.y,
1099
1320
  width: natural.width,
1100
1321
  height: natural.height,
1101
- baseline: natural.baseline - natural.y,
1322
+ baseline: natural.baseline - natural.y - lift,
1102
1323
  texts: [text],
1103
1324
  textStart: text.textStart,
1104
1325
  textEnd: text.textEnd,
@@ -1107,6 +1328,8 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1107
1328
  });
1108
1329
  widest = Math.max(widest, natural.width);
1109
1330
  }
1331
+ if (keepsSpaces)
1332
+ hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
1110
1333
  return { lines, height: layout.height, width: widest };
1111
1334
  }
1112
1335
  /** The code point a code-unit offset into a layout's text starts. */
@@ -1151,6 +1374,7 @@ export function layoutOffsetOf(text, doc, end = false) {
1151
1374
  * `place` says; returns the widest. */
1152
1375
  function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1153
1376
  let widest = 0;
1377
+ const lift = layoutLift(layout);
1154
1378
  for (let i = 0; i < layout.lines.length; i += 1) {
1155
1379
  const natural = layout.lines[i];
1156
1380
  const dx = place ? place(natural) : 0;
@@ -1158,7 +1382,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1158
1382
  layout,
1159
1383
  layoutLine: i,
1160
1384
  drawX: xOff + dx,
1161
- drawY: yOff,
1385
+ drawY: yOff - lift,
1162
1386
  textStart: spans.documentAt(natural.start),
1163
1387
  textEnd: spans.documentAt(natural.end),
1164
1388
  layoutStart: natural.start,
@@ -1170,7 +1394,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1170
1394
  y: yOff + natural.y,
1171
1395
  width: natural.width,
1172
1396
  height: natural.height,
1173
- baseline: natural.baseline - natural.y,
1397
+ baseline: natural.baseline - natural.y - lift,
1174
1398
  texts: [text],
1175
1399
  textStart: text.textStart,
1176
1400
  textEnd: text.textEnd,
@@ -1206,7 +1430,7 @@ const CHUNKS = {
1206
1430
  * breaking never crosses a required break), so the chunk seams are
1207
1431
  * invisible; each chunk stays a single batch a viewport can keep whole.
1208
1432
  */
1209
- function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS) {
1433
+ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS, keepsSpaces = false) {
1210
1434
  const wrapWords = overflowWrapOf(style, items);
1211
1435
  const lines = [];
1212
1436
  let widest = 0;
@@ -1217,7 +1441,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1217
1441
  let hardLines = 0;
1218
1442
  const { cut } = chunking;
1219
1443
  const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
1220
- const flush = () => {
1444
+ const flush = (last = false) => {
1221
1445
  if (!chunkRuns.length)
1222
1446
  return;
1223
1447
  const chunkOptions = {
@@ -1226,14 +1450,27 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1226
1450
  align,
1227
1451
  direction: style.direction,
1228
1452
  overflowWrap: cutWrap(style, cut, wrapWords),
1453
+ fit: PARAGRAPH_FIT,
1229
1454
  ...cut,
1230
1455
  };
1231
1456
  let layout = fonts.layout(chunkRuns, base, chunkOptions);
1232
1457
  LAYOUT_RUNS.set(layout, chunkRuns);
1458
+ const held = keepsSpaces
1459
+ ? holdAtBreaks(layout, chunkRuns, chunkSpans, fonts, chunkOptions.maxWidth ?? width, last && !cut)
1460
+ : null;
1461
+ if (held) {
1462
+ chunkRuns = held;
1463
+ layout = fonts.layout(chunkRuns, base, chunkOptions);
1464
+ LAYOUT_RUNS.set(layout, chunkRuns);
1465
+ }
1233
1466
  // a chunk ends at a forced break or at the paragraph's end, which is
1234
1467
  // where `justifiedRuns` takes a chunk's last line to end
1235
1468
  layout = justifiedLayout(fonts, chunkRuns, base, chunkOptions, layout, width, justification(style)).layout;
1469
+ const from = lines.length;
1236
1470
  widest = Math.max(widest, emitLayout(layout, chunkSpans, 0, y, lines, place));
1471
+ if (keepsSpaces) {
1472
+ hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
1473
+ }
1237
1474
  y += layout.height;
1238
1475
  chunkRuns = [];
1239
1476
  chunkSpans = new SpanMap();
@@ -1293,7 +1530,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1293
1530
  text = text.slice(cut);
1294
1531
  }
1295
1532
  }
1296
- flush();
1533
+ flush(true);
1297
1534
  return { lines, height: y, width: widest };
1298
1535
  }
1299
1536
  function openLine(indent) {
@@ -1314,6 +1551,250 @@ function breaksAtEnd(open, style) {
1314
1551
  return (wraps(style) &&
1315
1552
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic'));
1316
1553
  }
1554
+ /**
1555
+ * Whether a line may break before an inline box's opening edge, between
1556
+ * what the line has got to and `next`, the text after the edge: after an
1557
+ * atomic, or after the white space the line ends on, as `breaksAtEnd`
1558
+ * says, or where the text engine breaks the line's last text and `next`
1559
+ * run together (`breaksBetween`). An edge on the way is no place to break
1560
+ * of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
1561
+ * yet has none to hold on to.
1562
+ */
1563
+ function breaksAfterLine(open, style, fonts, base, next, anywhere) {
1564
+ if (!wraps(style))
1565
+ return false;
1566
+ if (open.hang > 0)
1567
+ return true;
1568
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1569
+ const placed = open.order[i];
1570
+ if (placed.kind === 'edge')
1571
+ continue;
1572
+ if (placed.kind === 'atomic')
1573
+ return true;
1574
+ const text = placed.item;
1575
+ const runs = LAYOUT_RUNS.get(text.layout);
1576
+ if (!runs || (runs.length > 0 && typeof runs[0] === 'number'))
1577
+ return true;
1578
+ const end = text.layout.lines[text.layoutLine].end;
1579
+ return breaksBetween(fonts, base, runs, end, next, anywhere);
1580
+ }
1581
+ return true;
1582
+ }
1583
+ /**
1584
+ * How wide the edges are between the text the open line ends on and text
1585
+ * starting at `start` in `runs`, where the one runs on into the other
1586
+ * (`JOINS`): neither is white space at the join, and no atomic, which has a
1587
+ * break before and after it, is between them. Undefined where they do not
1588
+ * join, or the line has no text yet. Letters on either side are taken to
1589
+ * join whatever they are, which may count an ideograph's join that the
1590
+ * engine would break at: it makes the bound it is for a larger one, never a
1591
+ * smaller.
1592
+ */
1593
+ function joinsOnto(open, runs, start) {
1594
+ if (open.hang > 0)
1595
+ return undefined;
1596
+ const next = charAt(runs, start);
1597
+ if (next === '' || SPACE.test(next))
1598
+ return undefined;
1599
+ let edges = 0;
1600
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1601
+ const placed = open.order[i];
1602
+ if (placed.kind === 'edge') {
1603
+ edges += placed.item.width;
1604
+ continue;
1605
+ }
1606
+ if (placed.kind === 'atomic')
1607
+ return undefined;
1608
+ const text = placed.item;
1609
+ const before = LAYOUT_RUNS.get(text.layout);
1610
+ if (!before || (before.length > 0 && typeof before[0] === 'number')) {
1611
+ return undefined;
1612
+ }
1613
+ const last = charAt(before, text.layout.lines[text.layoutLine].end - 1);
1614
+ return last === '' || SPACE.test(last) ? undefined : edges;
1615
+ }
1616
+ return undefined;
1617
+ }
1618
+ /**
1619
+ * Whether a line may break between two texts an inline box's edge stands
1620
+ * between — the text of `before` up to the code unit `end`, and `after` —
1621
+ * which is where it may break them run together, the edge being no place
1622
+ * to break (CSS Text 3, 5.1). After white space, and not before it, where
1623
+ * the break is after that white space instead (UAX #14, LB7). Never between
1624
+ * two letters or digits of the alphabets whose words break only at spaces,
1625
+ * which is every edge in running text, and needs no asking. Anything else
1626
+ * — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
1627
+ * of the text engine: the two words, laid out at no width, break at every
1628
+ * place a line may, which is UAX #14 as the `linebreak` package has it on
1629
+ * both backends — ntk breaks every line with it, and react-x11's CoreText
1630
+ * engine a text at no width. So an edge after a hyphen, or between two
1631
+ * ideographs, is a place to break exactly where the same text with no edge
1632
+ * in it is one; a list of classes here would be a third opinion.
1633
+ *
1634
+ * Counted, not located: the words run together break into as many pieces
1635
+ * as the two apart where they break between them, and one fewer where the
1636
+ * last piece of the one runs on into the first of the other. The line
1637
+ * offsets are no answer, as they are not the text's on every backend: the
1638
+ * CoreText engine lays out a copy with a line break put in at each place,
1639
+ * and its offsets are that copy's.
1640
+ */
1641
+ function breaksBetween(fonts, base, before, end, after, anywhere) {
1642
+ const last = charAt(before, end - 1);
1643
+ const next = charAt(after, 0);
1644
+ // nothing to hold the two together
1645
+ if (last === '' || next === '')
1646
+ return true;
1647
+ if (SPACE.test(last))
1648
+ return true;
1649
+ if (SPACE.test(next))
1650
+ return false;
1651
+ if (anywhere)
1652
+ return true;
1653
+ if (HOLDS.test(last) && HOLDS.test(next))
1654
+ return false;
1655
+ if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next))
1656
+ return false;
1657
+ const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
1658
+ const head = sliceRuns(after, 0, firstSpace(after));
1659
+ const pieces = (runs) => fonts.layout(runs, base, {
1660
+ maxWidth: MIN_CONTENT_WIDTH,
1661
+ overflowWrap: 'normal',
1662
+ }).lines.length;
1663
+ return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
1664
+ }
1665
+ /** The width a text is laid out at to break it at every place it may
1666
+ * break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
1667
+ * `MIN_CONTENT_PROBE`). */
1668
+ const MIN_CONTENT_WIDTH = 0;
1669
+ /** White space a line breaks after. */
1670
+ const SPACE = /^[ \t\n]$/;
1671
+ /** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
1672
+ * marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
1673
+ * LB25, LB28). */
1674
+ const HOLDS = /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
1675
+ /** What no line breaks after, whatever follows: an opening bracket, a
1676
+ * straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
1677
+ const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
1678
+ /** What no line breaks before, whatever precedes: a closing bracket, a
1679
+ * stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
1680
+ * LB13, LB19) — the punctuation after a link or a `<code>`, which needs
1681
+ * no asking either. */
1682
+ const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
1683
+ /** The code unit at an offset into runs' joined text, or '' outside it. */
1684
+ function charAt(runs, at) {
1685
+ if (at < 0)
1686
+ return '';
1687
+ let from = 0;
1688
+ for (const run of runs) {
1689
+ const next = from + run.text.length;
1690
+ if (at < next)
1691
+ return run.text[at - from];
1692
+ from = next;
1693
+ }
1694
+ return '';
1695
+ }
1696
+ /** Where the last white space before a code-unit offset is, or -1. */
1697
+ function lastSpace(runs, end) {
1698
+ let found = -1;
1699
+ let from = 0;
1700
+ for (const run of runs) {
1701
+ if (from >= end)
1702
+ break;
1703
+ const text = run.text.slice(0, end - from);
1704
+ const at = Math.max(text.lastIndexOf(' '), text.lastIndexOf('\t'), text.lastIndexOf('\n'));
1705
+ if (at >= 0)
1706
+ found = from + at;
1707
+ from += run.text.length;
1708
+ }
1709
+ return found;
1710
+ }
1711
+ /** Where the first white space in runs' joined text is, or its length. */
1712
+ function firstSpace(runs) {
1713
+ let from = 0;
1714
+ for (const run of runs) {
1715
+ const at = run.text.search(/[ \t\n]/);
1716
+ if (at >= 0)
1717
+ return from + at;
1718
+ from += run.text.length;
1719
+ }
1720
+ return from;
1721
+ }
1722
+ function runsLength(runs) {
1723
+ let length = 0;
1724
+ for (const run of runs)
1725
+ length += run.text.length;
1726
+ return length;
1727
+ }
1728
+ /**
1729
+ * The text inline boxes' edges from `from` on stand before, up to its first
1730
+ * white space: what the text before the edges runs on into. Null where an
1731
+ * atomic comes first, which has a break before it, or nothing does.
1732
+ */
1733
+ function textAfterEdges(items, from) {
1734
+ const runs = [];
1735
+ for (let i = from; i < items.length; i += 1) {
1736
+ const item = items[i];
1737
+ if (item.kind === 'edge' || item.kind === 'float')
1738
+ continue;
1739
+ if (item.kind === 'atomic')
1740
+ break;
1741
+ const text = item.run.text;
1742
+ const stop = text.search(/[ \t\n]/);
1743
+ if (stop < 0) {
1744
+ runs.push(item.run);
1745
+ continue;
1746
+ }
1747
+ runs.push({ ...item.run, text: text.slice(0, stop + 1) });
1748
+ break;
1749
+ }
1750
+ return runs.length ? runs : null;
1751
+ }
1752
+ /**
1753
+ * The most `unbreakableAfter` could come to, with no layout: the edges on
1754
+ * the way, and the text up to its first white space at half again its size
1755
+ * a character, which no glyph a document sets words in is wider than, and
1756
+ * its letter spacing (`longestWord`'s estimate).
1757
+ */
1758
+ function unbreakableBound(items, from) {
1759
+ let width = 0;
1760
+ for (let i = from; i < items.length; i += 1) {
1761
+ const item = items[i];
1762
+ if (item.kind === 'float')
1763
+ continue;
1764
+ if (item.kind === 'edge') {
1765
+ width += Math.max(0, item.width);
1766
+ continue;
1767
+ }
1768
+ if (item.kind === 'atomic')
1769
+ break;
1770
+ const run = item.run;
1771
+ const stop = run.text.search(/[ \t\n]/);
1772
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
1773
+ width += (stop < 0 ? run.text.length : stop) * per;
1774
+ if (stop >= 0)
1775
+ break;
1776
+ }
1777
+ return width;
1778
+ }
1779
+ /** How wide the edges from `from` on are that close inline boxes, up to
1780
+ * one that opens a box or anything but an edge or a float. */
1781
+ function closingEdges(items, from) {
1782
+ let width = 0;
1783
+ for (let i = from; i < items.length; i += 1) {
1784
+ const item = items[i];
1785
+ if (item.kind === 'float')
1786
+ continue;
1787
+ if (item.kind !== 'edge' || item.side !== 'end')
1788
+ break;
1789
+ width += item.width;
1790
+ }
1791
+ return width;
1792
+ }
1793
+ /** Whether a code-unit offset into runs' joined text is between two
1794
+ * letters or digits: inside a word, where only `overflow-wrap` breaks. */
1795
+ function insideWord(runs, at) {
1796
+ return (WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at)));
1797
+ }
1317
1798
  /**
1318
1799
  * How wide the content from `from` on is up to where its line may break:
1319
1800
  * its text up to a space it may break at — one `holdNoWrap` left a space —
@@ -1349,7 +1830,9 @@ function unbreakableAfter(items, from, style, fonts, base) {
1349
1830
  runs.push({ ...item.run, text: text.slice(0, stop) });
1350
1831
  break;
1351
1832
  }
1352
- return runs.length ? width + fonts.layout(runs, base, {}).width : width;
1833
+ return runs.length
1834
+ ? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
1835
+ : width;
1353
1836
  }
1354
1837
  /**
1355
1838
  * Close a line: its height and baseline from what is on it, then where it
@@ -1357,11 +1840,16 @@ function unbreakableAfter(items, from, style, fonts, base) {
1357
1840
  * it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
1358
1841
  * centred), moved onto every text, atomic and edge on it at once.
1359
1842
  */
1360
- function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null) {
1843
+ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null,
1844
+ /** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
1845
+ levels = null) {
1361
1846
  if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1362
1847
  return null;
1363
1848
  }
1364
- const reordered = open.order.some((p) => needsOrdering(p, rtl));
1849
+ const reordered = levels
1850
+ ? levelPieces(open, levels) ||
1851
+ open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
1852
+ : open.order.some((p) => needsOrdering(p, rtl));
1365
1853
  if (reordered)
1366
1854
  reorderLine(open, rtl);
1367
1855
  // Every line box starts with the block's strut, its face at its line
@@ -1404,7 +1892,9 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1404
1892
  continue;
1405
1893
  }
1406
1894
  const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1407
- const own = natural.baseline - natural.y;
1895
+ // with its leading shared as the strut's is: it is drawn on the line's
1896
+ // baseline, wherever its engine put its own
1897
+ const own = lineAscent(natural);
1408
1898
  ascent = Math.max(ascent, own);
1409
1899
  descent = Math.max(descent, natural.height - own);
1410
1900
  }
@@ -1570,10 +2060,8 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1570
2060
  * UAX #9's L2 over pieces rather than characters: a fragment is in order
1571
2061
  * inside itself already — the engine laid it out bidirectionally — so what
1572
2062
  * is left to order is the fragments and the atomics between them, each at
1573
- * a level — a fragment the direction its letters read in, or the
1574
- * paragraph's where they read both ways, and a neutral (an atomic, a
1575
- * fragment of spaces and digits) its neighbours' where they agree and the
1576
- * paragraph's where they do not (N1, N2).
2063
+ * a level: the paragraph's own, where it has them (`levelPieces`), and
2064
+ * otherwise the one it reads at (`readingLevels`).
1577
2065
  *
1578
2066
  * Then each element's edges, innermost first, around what of it is on the
1579
2067
  * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
@@ -1592,28 +2080,16 @@ function reorderLine(open, rtl) {
1592
2080
  for (let i = 0; i < n; i += 1)
1593
2081
  if (order[i].kind !== 'edge')
1594
2082
  content.push(i);
1595
- const strong = content.map((i) => {
2083
+ // UAX #9's levels where the paragraph's are known (`levelPieces`), each
2084
+ // piece at one of them; its reading otherwise
2085
+ const given = content.map((i) => {
1596
2086
  const p = order[i];
1597
- if (p.kind !== 'text' || p.reads === null)
1598
- return null;
1599
- return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2087
+ return p.kind === 'edge' ? undefined : p.level;
1600
2088
  });
1601
- const strongAt = (from, step) => {
1602
- for (let k = from + step; k >= 0 && k < content.length; k += step) {
1603
- if (strong[k] !== null)
1604
- return strong[k];
1605
- }
1606
- return rtl;
1607
- };
1608
- const isRtl = strong.map((r, k) => {
1609
- if (r !== null)
1610
- return r;
1611
- const before = strongAt(k, -1);
1612
- return before === strongAt(k, 1) ? before : rtl;
1613
- });
1614
- // R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
1615
- // in a right-to-left one
1616
- const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
2089
+ const levels = given.every((level) => level !== undefined)
2090
+ ? given
2091
+ : readingLevels(order, content, rtl);
2092
+ const isRtl = levels.map((level) => (level & 1) === 1);
1617
2093
  const visual = content.map((_, k) => k);
1618
2094
  for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
1619
2095
  for (let a = 0; a < visual.length;) {
@@ -1688,6 +2164,9 @@ function reorderLine(open, rtl) {
1688
2164
  if (isRtl[k] && natural)
1689
2165
  dx += Math.max(0, room - natural.width);
1690
2166
  piece.item.drawX += dx;
2167
+ // and the spaces it hung are on the side of it its room is left on
2168
+ if (piece.item.hung)
2169
+ placeHung(piece.item, isRtl[k]);
1691
2170
  }
1692
2171
  else {
1693
2172
  piece.item.x += dx;
@@ -1695,6 +2174,399 @@ function reorderLine(open, rtl) {
1695
2174
  x += room;
1696
2175
  }
1697
2176
  }
2177
+ /**
2178
+ * The levels of a line's pieces from which way each reads, where the
2179
+ * paragraph's are not known: a fragment the direction its letters read in,
2180
+ * or the paragraph's where they read both ways, and a neutral — an atomic, a
2181
+ * fragment of spaces and digits — its neighbours' where they agree and the
2182
+ * paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
2183
+ * L is 0 in a left-to-right one and 2 in a right-to-left one.
2184
+ */
2185
+ function readingLevels(order, content, rtl) {
2186
+ const strong = content.map((i) => {
2187
+ const p = order[i];
2188
+ if (p.kind !== 'text' || p.reads === null)
2189
+ return null;
2190
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2191
+ });
2192
+ const strongAt = (from, step) => {
2193
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2194
+ if (strong[k] !== null)
2195
+ return strong[k];
2196
+ }
2197
+ return rtl;
2198
+ };
2199
+ return strong.map((r, k) => {
2200
+ const reads = r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
2201
+ return reads ? 1 : rtl ? 2 : 0;
2202
+ });
2203
+ }
2204
+ let BIDI = null;
2205
+ /** bidi-js, made the first time a paragraph needs it. Its declarations
2206
+ * give a CommonJS module a default export, which NodeNext reads as the
2207
+ * module object; Node and a bundler both hand over its factory. */
2208
+ function bidiJs() {
2209
+ if (!BIDI) {
2210
+ const factory = bidiModule;
2211
+ BIDI = (typeof factory === 'function' ? factory : factory.default)();
2212
+ }
2213
+ return BIDI;
2214
+ }
2215
+ /** A paragraph's levels, or null where nothing in it can be reordered:
2216
+ * it reads left to right, and holds no right-to-left letter and no bidi
2217
+ * control. Kept on its block, and resolved again only where its text is
2218
+ * not what it was: a pass at another width has the same. */
2219
+ function paragraphBidi(block, items, rtl) {
2220
+ let reorders = rtl;
2221
+ for (let i = 0; !reorders && i < items.length; i += 1) {
2222
+ const item = items[i];
2223
+ if (item.kind === 'text' && REORDERS.test(item.run.text))
2224
+ reorders = true;
2225
+ }
2226
+ if (!reorders)
2227
+ return null;
2228
+ const starts = [];
2229
+ let text = '';
2230
+ for (const item of items) {
2231
+ starts.push(text.length);
2232
+ if (item.kind === 'text')
2233
+ text += item.run.text;
2234
+ else if (item.kind === 'atomic')
2235
+ text += '\ufffc';
2236
+ }
2237
+ const base = rtl ? 1 : 0;
2238
+ const kept = PARAGRAPH_BIDI.get(block);
2239
+ if (kept && kept.text === text && kept.base === base) {
2240
+ return { ...kept, starts };
2241
+ }
2242
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2243
+ const explicit = EXPLICIT.test(text);
2244
+ const bidi = { text, levels, explicit, base, starts };
2245
+ PARAGRAPH_BIDI.set(block, bidi);
2246
+ return bidi;
2247
+ }
2248
+ const PARAGRAPH_BIDI = new WeakMap();
2249
+ /** The explicit embeddings, overrides and isolates. */
2250
+ const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
2251
+ /** The explicit embeddings, overrides and isolates a text ends on. */
2252
+ const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
2253
+ /** Whether a character is one X9 removes, or an isolate: a bidi control,
2254
+ * or a boundary neutral such as a zero-width space or a soft hyphen. */
2255
+ function isControl(c) {
2256
+ return ((c >= 0x202a && c <= 0x202e) ||
2257
+ (c >= 0x2066 && c <= 0x2069) ||
2258
+ (c >= 0x200b && c <= 0x200d) ||
2259
+ (c >= 0x2060 && c <= 0x2064) ||
2260
+ c === 0xad ||
2261
+ c === 0xfeff ||
2262
+ c === 0x180e);
2263
+ }
2264
+ /** The paired brackets UAX #9 resolves together (N0), which may pair
2265
+ * across a piece's end. */
2266
+ const BRACKETS = /[()[\]{}\u0f3a-\u0f3d\u169b\u169c\u2045\u2046\u207d\u207e\u208d\u208e\u2308-\u230b\u2329\u232a\u2768-\u2775\u27c5\u27c6\u27e6-\u27ef\u2983-\u2998\u29d8-\u29db\u29fc\u29fd\u2e22-\u2e29\u3008-\u3011\u3014-\u301b\ufe59-\ufe5e\uff08\uff09\uff3b\uff3d\uff5b\uff5d\uff5f\uff60\uff62\uff63]/;
2267
+ /** The strong types, which nothing around them resolves again. */
2268
+ const STRONG = new Set(['L', 'R', 'AL']);
2269
+ /** What UAX #9 L1 puts back at the paragraph's level where a line ends on
2270
+ * it: white space, the isolates, and what X9 removed. */
2271
+ const TRAILING = new Set([
2272
+ 'WS',
2273
+ 'S',
2274
+ 'B',
2275
+ 'LRI',
2276
+ 'RLI',
2277
+ 'FSI',
2278
+ 'PDI',
2279
+ 'BN',
2280
+ 'LRE',
2281
+ 'RLE',
2282
+ 'LRO',
2283
+ 'RLO',
2284
+ 'PDF',
2285
+ ]);
2286
+ /** A segment's text, and its levels as the engine resolves them, laid out
2287
+ * alone in the paragraph's direction. */
2288
+ const ALONE = new WeakMap();
2289
+ function aloneLevels(runs, rtl) {
2290
+ let alone = ALONE.get(runs);
2291
+ if (!alone) {
2292
+ const text = runs.map((run) => run.text).join('');
2293
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2294
+ alone = { text, levels };
2295
+ ALONE.set(runs, alone);
2296
+ }
2297
+ return alone;
2298
+ }
2299
+ /** Whether the text from a segment on, laid out alone, is ordered as the
2300
+ * paragraph orders it: every letter at the level it has there. */
2301
+ function orderedAlone(bidi, segment, para, rtl) {
2302
+ const { text, levels } = aloneLevels(segment.runs, rtl);
2303
+ for (let at = 0; at < levels.length; at += 1) {
2304
+ if (segment.spans.isGap(at))
2305
+ continue;
2306
+ if (levels[at] === bidi.levels[para + at])
2307
+ continue;
2308
+ if (!isControl(text.charCodeAt(at)))
2309
+ return false;
2310
+ }
2311
+ return true;
2312
+ }
2313
+ /**
2314
+ * Give each piece on a line its level, and answer whether the line is to be
2315
+ * put in visual order at all. A piece's letters have the paragraph's levels,
2316
+ * with the white space the line ends on back at the paragraph's (L1). A
2317
+ * fragment was laid out alone and ordered inside itself by the engine: it
2318
+ * is kept whole where the engine's levels for it are the paragraph's and it
2319
+ * begins and ends at its lowest one, so that nothing of another piece comes
2320
+ * between its letters (L2), and it is the line's at that level. It is split
2321
+ * otherwise, a layout to each run of its letters at one level, held in that
2322
+ * level's direction by an override; the controls in it, which take no
2323
+ * room, are left out. An atomic is at its replacement character's level.
2324
+ */
2325
+ function levelPieces(open, line) {
2326
+ const { bidi } = line;
2327
+ let start = Infinity;
2328
+ let end = -Infinity;
2329
+ for (const p of open.order) {
2330
+ if (p.kind === 'text' && p.from) {
2331
+ const natural = p.item.layout.lines[p.item.layoutLine];
2332
+ if (!natural)
2333
+ continue;
2334
+ start = Math.min(start, p.from.para + natural.start);
2335
+ end = Math.max(end, p.from.para + natural.end);
2336
+ }
2337
+ else if (p.kind === 'atomic' && p.para !== undefined) {
2338
+ start = Math.min(start, p.para);
2339
+ end = Math.max(end, p.para + 1);
2340
+ }
2341
+ }
2342
+ // an element's edges alone are ordered as the paragraph reads
2343
+ if (!(end > start))
2344
+ return bidi.base > 0;
2345
+ const levels = bidi.levels.slice(start, end);
2346
+ const js = bidiJs();
2347
+ for (let i = end - 1; i >= start; i -= 1) {
2348
+ if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i])))
2349
+ break;
2350
+ levels[i - start] = bidi.base;
2351
+ }
2352
+ const levelAt = (at) => levels[at - start];
2353
+ let off = false;
2354
+ const order = [];
2355
+ const texts = [];
2356
+ for (const p of open.order) {
2357
+ if (p.kind === 'edge') {
2358
+ order.push(p);
2359
+ continue;
2360
+ }
2361
+ if (p.kind === 'atomic') {
2362
+ p.level = p.para === undefined ? bidi.base : levelAt(p.para);
2363
+ off ||= p.level > 0;
2364
+ order.push(p);
2365
+ continue;
2366
+ }
2367
+ for (const piece of levelled(p, levelAt, line)) {
2368
+ off ||= piece.level > 0;
2369
+ order.push(piece);
2370
+ texts.push(piece.item);
2371
+ }
2372
+ }
2373
+ open.order = order;
2374
+ open.texts = texts;
2375
+ return off;
2376
+ }
2377
+ /** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
2378
+ function levelled(p, levelAt, line) {
2379
+ const text = p.item;
2380
+ const natural = text.layout.lines[text.layoutLine];
2381
+ const base = line.bidi.base;
2382
+ if (!p.from || !natural)
2383
+ return [{ ...p, level: base }];
2384
+ const { para, runs, spans } = p.from;
2385
+ const paragraph = line.bidi.text;
2386
+ // A bidi control takes no room and has no level of its own, whether
2387
+ // `unicode-bidi` stands for it or the document holds it: X9 removes it,
2388
+ // and bidi-js gives it the level of what is before it, which may be
2389
+ // another piece's. It is no end of a piece, nor of a run of one level.
2390
+ const quiet = (at) => spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
2391
+ let lowest = Infinity;
2392
+ let first = -1;
2393
+ let last = -1;
2394
+ for (let at = natural.start; at < natural.end; at += 1) {
2395
+ if (quiet(at))
2396
+ continue;
2397
+ if (first < 0)
2398
+ first = at;
2399
+ last = at;
2400
+ lowest = Math.min(lowest, levelAt(para + at));
2401
+ }
2402
+ if (first < 0)
2403
+ return [{ ...p, level: base }];
2404
+ if (levelAt(para + first) === lowest &&
2405
+ levelAt(para + last) === lowest &&
2406
+ (plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
2407
+ laidAsLevelled(p.from, natural, levelAt, base))) {
2408
+ return [{ ...p, level: lowest }];
2409
+ }
2410
+ const starts = runStarts(runs);
2411
+ const out = [];
2412
+ let at = p.at;
2413
+ let lastRuns = [];
2414
+ /** Where each piece is in the fragment's segment. */
2415
+ const ranges = [];
2416
+ const piece = (from, to, level) => {
2417
+ // its letters, each run's with where it is in the document and whose
2418
+ // text it is
2419
+ const letters = [];
2420
+ let points = 0;
2421
+ for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
2422
+ const runStart = starts[k];
2423
+ if (runStart >= to)
2424
+ break;
2425
+ const a = Math.max(from, runStart);
2426
+ const b = Math.min(to, runStart + runs[k].text.length);
2427
+ if (b <= a || spans.isGap(a))
2428
+ continue;
2429
+ const slice = runs[k].text.slice(a - runStart, b - runStart);
2430
+ letters.push({
2431
+ run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
2432
+ doc: spans.documentAt(a),
2433
+ box: spans.boxAt(a),
2434
+ });
2435
+ points += codeUnitOffsets(slice).length - 1;
2436
+ }
2437
+ if (!letters.length)
2438
+ return;
2439
+ const head = letters[0];
2440
+ const tail = letters[letters.length - 1];
2441
+ const docStart = head.doc;
2442
+ const docEnd = tail.doc + tail.run.text.length;
2443
+ const face = (run, control) => ({
2444
+ text: control,
2445
+ family: run.family,
2446
+ size: run.size,
2447
+ weight: run.weight,
2448
+ style: run.style,
2449
+ });
2450
+ const rtl = (level & 1) === 1;
2451
+ const laidRuns = [face(head.run, rtl ? '\u202e' : '\u202d')];
2452
+ const laidSpans = new SpanMap();
2453
+ laidSpans.add(docStart, 1, head.box, true);
2454
+ for (const { run, doc, box } of letters) {
2455
+ laidSpans.add(doc, run.text.length, box);
2456
+ laidRuns.push(run);
2457
+ }
2458
+ laidRuns.push(face(tail.run, '\u202c'));
2459
+ laidSpans.add(docEnd, 1, tail.box, true);
2460
+ const layout = line.fonts.layout(laidRuns, line.base, {
2461
+ lineHeight: line.lineHeight,
2462
+ align: 'left',
2463
+ direction: rtl ? 'rtl' : 'ltr',
2464
+ fit: PARAGRAPH_FIT,
2465
+ });
2466
+ LAYOUT_RUNS.set(layout, laidRuns);
2467
+ lastRuns = laidRuns;
2468
+ const laid = layout.lines[0];
2469
+ const item = {
2470
+ layout,
2471
+ layoutLine: 0,
2472
+ drawX: text.drawX + (at - p.at),
2473
+ drawY: 0,
2474
+ textStart: docStart,
2475
+ textEnd: docEnd,
2476
+ layoutStart: laid ? laid.start : 0,
2477
+ spans: laidSpans,
2478
+ };
2479
+ laidSpans.giveGaps(item, 0, laidSpans.laidOut);
2480
+ out.push({ kind: 'text', at, item, reads: null, level });
2481
+ ranges.push([from, to]);
2482
+ // the room it takes, its white space at its end with it, which the
2483
+ // engine hangs past the layout's width: from caret to caret
2484
+ const extent = Math.abs(layout.caretPosition(1 + points).x - layout.caretPosition(1).x);
2485
+ at += Math.max(laid?.width ?? 0, extent);
2486
+ };
2487
+ let from = first;
2488
+ let level = levelAt(para + first);
2489
+ let prev = first;
2490
+ for (let o = first + 1; o <= last; o += 1) {
2491
+ if (quiet(o))
2492
+ continue;
2493
+ const l = levelAt(para + o);
2494
+ if (l !== level) {
2495
+ piece(from, prev + 1, level);
2496
+ from = o;
2497
+ level = l;
2498
+ }
2499
+ prev = o;
2500
+ }
2501
+ piece(from, last + 1, level);
2502
+ if (!out.length)
2503
+ return [{ ...p, level: base }];
2504
+ // a word across two of the pieces runs on from one to the next, with no
2505
+ // edge between, as the fragment ran on from the text before it (`JOINS`)
2506
+ const joins = JOINS.get(text);
2507
+ if (joins !== undefined)
2508
+ JOINS.set(out[0].item, joins);
2509
+ for (let k = 1; k < out.length; k += 1) {
2510
+ const before = paragraph[para + ranges[k - 1][1] - 1];
2511
+ const after = paragraph[para + ranges[k][0]];
2512
+ if (!SPACE.test(before) && !SPACE.test(after))
2513
+ JOINS.set(out[k].item, 0);
2514
+ }
2515
+ // the spaces `pre-wrap` keeps that the fragment ended on are its last
2516
+ // piece's, which the engine hangs as it hung them (`hangPreserved`)
2517
+ const tail = out[out.length - 1];
2518
+ if (text.hung) {
2519
+ const rtl = (tail.level & 1) === 1;
2520
+ hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
2521
+ }
2522
+ return out;
2523
+ }
2524
+ /**
2525
+ * Whether the engine, laying a fragment out alone, must have given its
2526
+ * letters the paragraph's levels, without resolving them to see: in a
2527
+ * paragraph of no explicit embedding, a stretch that begins and ends with a
2528
+ * strong letter and pairs no bracket is resolved inside itself, whatever is
2529
+ * around it (W1 to W7, N1 and N2 look no further than the strong letters
2530
+ * either side). The white space it ends on is the paragraph's level to the
2531
+ * engine, whose line ends there (L1), and has to be to the line.
2532
+ */
2533
+ function plainlyLevelled(bidi, from, first, last, levelAt) {
2534
+ if (bidi.explicit)
2535
+ return false;
2536
+ const text = bidi.text;
2537
+ const js = bidiJs();
2538
+ let end = last;
2539
+ while (end > first &&
2540
+ TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))) {
2541
+ if (levelAt(from.para + end) !== bidi.base)
2542
+ return false;
2543
+ end -= 1;
2544
+ }
2545
+ return (STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
2546
+ STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
2547
+ !BRACKETS.test(text.slice(from.para + first, from.para + end + 1)));
2548
+ }
2549
+ /** Whether the engine, laying a fragment out alone, gave its letters the
2550
+ * levels the paragraph gives them: with its line's end white space back
2551
+ * at the paragraph's level (L1), as the engine's own line ends there. */
2552
+ function laidAsLevelled(from, natural, levelAt, base) {
2553
+ const { text, levels } = aloneLevels(from.runs, base === 1);
2554
+ const js = bidiJs();
2555
+ const quiet = (at) => from.spans.isGap(at) || isControl(text.charCodeAt(at));
2556
+ let hung = natural.end;
2557
+ while (hung > natural.start &&
2558
+ TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))) {
2559
+ hung -= 1;
2560
+ }
2561
+ for (let at = natural.start; at < natural.end; at += 1) {
2562
+ if (quiet(at))
2563
+ continue;
2564
+ const alone = at >= hung ? base : levels[at];
2565
+ if (alone !== levelAt(from.para + at))
2566
+ return false;
2567
+ }
2568
+ return true;
2569
+ }
1698
2570
  /** Whether a piece can be out of place in logical order: anything on a
1699
2571
  * right-to-left line, a fragment that reads right to left, or the edge of
1700
2572
  * an element whose direction is not the line's. A line of left-to-right
@@ -1812,7 +2684,14 @@ function atomicRaise(fonts, box, parent) {
1812
2684
  function atomicBaseline(box) {
1813
2685
  const bottom = box.height + box.marginTop + box.marginBottom;
1814
2686
  if (box.kind === 'replaced' ||
1815
- scrolls(box.style) ||
2687
+ // a box that clips sits on its bottom margin edge, for legacy reasons
2688
+ // that stop at block containers (CSS Box Alignment 3, 9.2) — and an
2689
+ // inline table, as browsers keep it: an inline flex box that clips
2690
+ // sits on its first item's, as it does unclipped. A MediaWiki button
2691
+ // is an `overflow: hidden` inline flex box of one icon, and stood its
2692
+ // whole height on the baseline, the line under it the strut's descent
2693
+ // taller
2694
+ (scrolls(box.style) && box.kind !== 'flex') ||
1816
2695
  // layout containment keeps its baseline in (CSS Containment 2, 3.3)
1817
2696
  box.style.contain & CONTAIN_LAYOUT) {
1818
2697
  return bottom;
@@ -1945,7 +2824,11 @@ function childBaseline(child, inside) {
1945
2824
  if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
1946
2825
  return null;
1947
2826
  }
1948
- if (scrolls(child.style)) {
2827
+ // the legacy rule is a block container's last baseline alone (CSS Box
2828
+ // Alignment 3, 9.2): its first is its first line's, clipped or not
2829
+ if (inside === lastBaselineIn &&
2830
+ child.kind !== 'flex' &&
2831
+ scrolls(child.style)) {
1949
2832
  return child.y + child.height + child.marginBottom;
1950
2833
  }
1951
2834
  return inside(child);
@@ -2308,7 +3191,12 @@ function wordSpaced(out, box, run, spacing) {
2308
3191
  run: {
2309
3192
  ...run,
2310
3193
  text: text.slice(from, to),
2311
- ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
3194
+ ...(extra
3195
+ ? {
3196
+ letterSpacing: (run.letterSpacing ?? 0) + extra,
3197
+ kernAcross: true,
3198
+ }
3199
+ : null),
2312
3200
  },
2313
3201
  box,
2314
3202
  length: to - from,
@@ -2354,6 +3242,11 @@ function inlineEdges(box, width, fonts) {
2354
3242
  }
2355
3243
  }
2356
3244
  box.decoration = inlineDecoration(fonts, box, s);
3245
+ if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
3246
+ PADDED_FACES.set(box, faceExtent(fonts, s));
3247
+ }
3248
+ else
3249
+ PADDED_FACES.delete(box);
2357
3250
  const left = box.marginLeft + box.borderLeft + box.padLeft;
2358
3251
  const right = box.padRight + box.borderRight + box.marginRight;
2359
3252
  return s.direction === 'rtl' ? [right, left] : [left, right];
@@ -2365,7 +3258,12 @@ function inlineEdges(box, width, fonts) {
2365
3258
  * a pointer move that gives a link a background (`HtmlViewNode`).
2366
3259
  */
2367
3260
  export function inlineDecoration(fonts, box, s) {
3261
+ // a background is a colour, a gradient or an image: a span a gradient
3262
+ // underlines, a highlighter's, was painted only where it had a border
2368
3263
  const decorated = !isTransparent(s.backgroundColor) ||
3264
+ s.backgroundGradient !== null ||
3265
+ s.backgroundImage !== null ||
3266
+ s.backgroundImages !== null ||
2369
3267
  box.borderTop + box.borderRight + box.borderBottom + box.borderLeft > 0 ||
2370
3268
  s.outlineStyle !== 'none';
2371
3269
  return !decorated ? null : fonts ? faceExtent(fonts, s) : NO_EXTENT;
@@ -2434,10 +3332,27 @@ function firstLineSetting(block, items, how, fonts, strut, justify) {
2434
3332
  const of = (box) => box === block || !box.parent
2435
3333
  ? style
2436
3334
  : styleOnFirstLine(box, of(box.parent), how.styler);
3335
+ // and in its `text-transform` where the line has one of its own, which
3336
+ // the boxes' text was not made with: a word carried from one run into
3337
+ // the next, as the box builder carries it (`letteredAfter`)
3338
+ let lettered = false;
2437
3339
  const restyled = items.map((item) => {
3340
+ if (item.kind === 'atomic')
3341
+ lettered = false;
2438
3342
  if (item.kind !== 'text')
2439
3343
  return item;
2440
- const run = restyledRun(item.run, item.box.style, of(item.box));
3344
+ const own = item.box.style;
3345
+ const on = of(item.box);
3346
+ let run = restyledRun(item.run, own, on);
3347
+ const transform = on.textTransform;
3348
+ if (!item.control &&
3349
+ transform !== own.textTransform &&
3350
+ transform !== 'none') {
3351
+ const text = transformInPlace(run.text, transform, lettered);
3352
+ if (text !== run.text)
3353
+ run = { ...run, text };
3354
+ }
3355
+ lettered = letteredAfter(run.text, lettered);
2441
3356
  return run === item.run ? item : { ...item, run };
2442
3357
  });
2443
3358
  const lineHeight = lineHeightMultiplier(fonts, style);
@@ -2761,6 +3676,180 @@ function heldText(box) {
2761
3676
  return held;
2762
3677
  }
2763
3678
  const HELD = new WeakMap();
3679
+ /**
3680
+ * Whether a line one layout made is shorter about its baseline than the
3681
+ * block's strut. Every line box starts with the strut, the block's font at
3682
+ * its line height (CSS 2.1 10.8.1), and a layout has none: it makes a line
3683
+ * as tall as the text on it, at the block's line height as a multiple of
3684
+ * each face's natural one. So a line of `<small>` text alone came out as
3685
+ * short as the small text, and overlapped what followed. A paragraph with
3686
+ * such a line is made a line at a time instead, where `finishLine` puts
3687
+ * the strut on every line; one whose lines all hold text of the block's
3688
+ * own face is kept as it was laid out.
3689
+ */
3690
+ function shortOfStrut(lines, strut,
3691
+ /** The first line's, where it holds more: a marker's room. */
3692
+ first = null) {
3693
+ for (let i = 0; i < lines.length; i += 1) {
3694
+ const line = lines[i];
3695
+ const holds = i === 0 && first ? first : strut;
3696
+ if (!holds)
3697
+ continue;
3698
+ // its room as the strut's is worked out, whichever baseline the layout
3699
+ // it is drawn with could keep (`layoutLift`)
3700
+ const text = line.texts.length === 1 ? line.texts[0] : null;
3701
+ const natural = text?.layout.lines[text.layoutLine];
3702
+ const ascent = natural ? lineAscent(natural) : line.baseline;
3703
+ if (ascent < holds.ascent - 0.5 ||
3704
+ line.height - ascent < holds.descent - 0.5) {
3705
+ return true;
3706
+ }
3707
+ }
3708
+ return false;
3709
+ }
3710
+ /** A strut with room besides it about the same baseline, or the strut
3711
+ * where there is none. */
3712
+ function withRoom(strut, room) {
3713
+ if (!room)
3714
+ return strut;
3715
+ if (!strut)
3716
+ return room;
3717
+ return {
3718
+ ascent: Math.max(strut.ascent, room.ascent),
3719
+ descent: Math.max(strut.descent, room.descent),
3720
+ };
3721
+ }
3722
+ /** The share of the size a synthesized small capital is set at, rounded to
3723
+ * a whole pixel as Blink rounds it (`SimpleFontData`'s scaled font data):
3724
+ * CSS Fonts 4 (6.2) leaves the size to the user agent. */
3725
+ const SMALL_CAPS_SCALE = 0.7;
3726
+ /**
3727
+ * Small capitals a face does not have, made of its capitals set smaller
3728
+ * (CSS Fonts 4, 6.2): where `font-variant-caps` asks for `smcp` — or
3729
+ * `c2sc` too, for `all-small-caps` — and the face does not answer it,
3730
+ * each letter that would be a small capital is its capital at 70% of the
3731
+ * size, in a run of its own: a lower-case letter, and for `all-small-caps`
3732
+ * the rest of the text too. Under `small-caps` what has no case — a space,
3733
+ * a digit, a mark of punctuation — keeps its size, as Blink keeps it. Only
3734
+ * a letter whose capital is one character is made one, so every offset
3735
+ * holds.
3736
+ */
3737
+ function synthesizeSmallCaps(items, fonts) {
3738
+ for (let i = 0; i < items.length; i++) {
3739
+ const item = items[i];
3740
+ if (item.kind !== 'text' || item.control)
3741
+ continue;
3742
+ // `font-variant-caps`'s, which is synthesized where the face has none;
3743
+ // not `font-feature-settings`', which asks for the feature as it is
3744
+ const caps = item.box.style.fontVariantCaps;
3745
+ const features = item.run.features;
3746
+ if (!caps.includes('smcp') || !features?.smcp)
3747
+ continue;
3748
+ const all = caps.includes('c2sc');
3749
+ if (hasSmallCaps(fonts, item.run, all))
3750
+ continue;
3751
+ const pieces = smallCapsPieces(item, all, withoutCaps(features));
3752
+ if (!pieces)
3753
+ continue;
3754
+ items.splice(i, 1, ...pieces);
3755
+ i += pieces.length - 1;
3756
+ }
3757
+ }
3758
+ /** A text item cut where its letters turn to synthesized small capitals
3759
+ * and back, or null where none does. */
3760
+ function smallCapsPieces(item, all, features) {
3761
+ const text = item.run.text;
3762
+ const pieces = [];
3763
+ let from = 0;
3764
+ let small = false;
3765
+ let capitals = '';
3766
+ const cut = (to) => {
3767
+ if (to === from)
3768
+ return;
3769
+ const run = small
3770
+ ? {
3771
+ ...item.run,
3772
+ text: capitals,
3773
+ size: Math.round((item.run.size ?? 16) * SMALL_CAPS_SCALE),
3774
+ }
3775
+ : { ...item.run, text: text.slice(from, to) };
3776
+ if (small) {
3777
+ if (features)
3778
+ run.features = features;
3779
+ else
3780
+ delete run.features;
3781
+ }
3782
+ pieces.push({ ...item, run, start: item.start + from, length: to - from });
3783
+ from = to;
3784
+ capitals = '';
3785
+ };
3786
+ let any = false;
3787
+ for (let at = 0; at < text.length;) {
3788
+ const ch = String.fromCodePoint(text.codePointAt(at));
3789
+ const upper = ch.toUpperCase();
3790
+ // everything, for `all-small-caps`: its spaces are small too
3791
+ const becomes = (all || upper !== ch) && upper.length === ch.length;
3792
+ if (becomes !== small) {
3793
+ cut(at);
3794
+ small = becomes;
3795
+ }
3796
+ if (becomes) {
3797
+ capitals += upper;
3798
+ any = true;
3799
+ }
3800
+ at += ch.length;
3801
+ }
3802
+ cut(text.length);
3803
+ return any ? pieces : null;
3804
+ }
3805
+ /** Features without the small capitals a synthesis stands in for, one
3806
+ * object for each set, as `featuresOf` keeps them. */
3807
+ function withoutCaps(features) {
3808
+ let kept = WITHOUT_CAPS.get(features);
3809
+ if (kept === undefined) {
3810
+ const out = {};
3811
+ for (const [tag, value] of Object.entries(features)) {
3812
+ if (tag !== 'smcp' && tag !== 'c2sc')
3813
+ out[tag] = value;
3814
+ }
3815
+ kept = Object.keys(out).length ? out : null;
3816
+ WITHOUT_CAPS.set(features, kept);
3817
+ }
3818
+ return kept;
3819
+ }
3820
+ const WITHOUT_CAPS = new WeakMap();
3821
+ /**
3822
+ * Whether a run's face has small capitals of its own — and capitals to
3823
+ * small capitals, for `all-small-caps`: the alphabet set with the feature
3824
+ * and without it comes out another width where it does. Asked of the
3825
+ * engine's layout rather than of the font's tables, which a CoreText face
3826
+ * does not hand over. Kept per face.
3827
+ */
3828
+ function hasSmallCaps(fonts, run, all) {
3829
+ let known = SMALL_CAPS.get(fonts);
3830
+ if (!known)
3831
+ SMALL_CAPS.set(fonts, (known = new Map()));
3832
+ const key = `${run.family}|${run.weight}|${run.style}|${all}`;
3833
+ let has = known.get(key);
3834
+ if (has === undefined) {
3835
+ const face = {
3836
+ family: run.family,
3837
+ weight: run.weight,
3838
+ style: run.style,
3839
+ size: 16,
3840
+ };
3841
+ const width = (text, features) => fonts.layout([{ text, ...face, ...(features ? { features } : null) }], face, {}).width;
3842
+ const lower = 'abcdefghijklmnopqrstuvwxyz';
3843
+ has = Math.abs(width(lower, { smcp: 1 }) - width(lower, null)) > 0.01;
3844
+ if (has && all) {
3845
+ const upper = lower.toUpperCase();
3846
+ has = Math.abs(width(upper, { c2sc: 1 }) - width(upper, null)) > 0.01;
3847
+ }
3848
+ known.set(key, has);
3849
+ }
3850
+ return has;
3851
+ }
3852
+ const SMALL_CAPS = new WeakMap();
2764
3853
  /**
2765
3854
  * The OpenType features a style's text is shaped with: the `font-variant`
2766
3855
  * longhands' and `font-kerning`'s, then `font-feature-settings`, which has
@@ -2872,11 +3961,15 @@ class SpanMap {
2872
3961
  * a spacer, a bidi control. */
2873
3962
  _gaps = null;
2874
3963
  laidOut = 0;
2875
- add(documentStart, length, box = null) {
3964
+ add(documentStart, length, box = null,
3965
+ /** No text of the document's, though a box's: a bidi control a
3966
+ * level's layout is wrapped in (`levelPieces`), which answers for
3967
+ * the element around it. */
3968
+ gap = box === null) {
2876
3969
  this._laid.push(this.laidOut);
2877
3970
  this._doc.push(documentStart);
2878
3971
  this._boxes.push(box);
2879
- if (!box) {
3972
+ if (gap) {
2880
3973
  this._gaps ??= [];
2881
3974
  for (let i = 0; i < length; i += 1)
2882
3975
  this._gaps.push(this.laidOut + i);
@@ -2893,6 +3986,23 @@ class SpanMap {
2893
3986
  if (gaps.length)
2894
3987
  text.gaps = gaps;
2895
3988
  }
3989
+ /** Whether an offset in the laid-out text is no text of the
3990
+ * document's: a spacer, a bidi control. */
3991
+ isGap(offset) {
3992
+ const gaps = this._gaps;
3993
+ if (!gaps)
3994
+ return false;
3995
+ let lo = 0;
3996
+ let hi = gaps.length;
3997
+ while (lo < hi) {
3998
+ const mid = (lo + hi) >> 1;
3999
+ if (gaps[mid] < offset)
4000
+ lo = mid + 1;
4001
+ else
4002
+ hi = mid;
4003
+ }
4004
+ return gaps[lo] === offset;
4005
+ }
2896
4006
  /** The text box whose text an offset in the laid-out text is. */
2897
4007
  boxAt(offset) {
2898
4008
  const i = this._entryAt(offset);
@@ -3023,6 +4133,165 @@ export function hungSpaces(fonts, run) {
3023
4133
  }
3024
4134
  return hung;
3025
4135
  }
4136
+ /** Where each run starts in their joined text. */
4137
+ function runStarts(runs) {
4138
+ const starts = [];
4139
+ let at = 0;
4140
+ for (const run of runs) {
4141
+ starts.push(at);
4142
+ at += run.text.length;
4143
+ }
4144
+ return starts;
4145
+ }
4146
+ /** The run a code-unit offset in the runs' joined text falls in. */
4147
+ function runIndexAt(starts, offset) {
4148
+ let lo = 0;
4149
+ let hi = starts.length - 1;
4150
+ while (lo < hi) {
4151
+ const mid = (lo + hi + 1) >> 1;
4152
+ if (starts[mid] <= offset)
4153
+ lo = mid;
4154
+ else
4155
+ hi = mid - 1;
4156
+ }
4157
+ return lo;
4158
+ }
4159
+ /**
4160
+ * The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
4161
+ * logical order and each with its advance, and whether a forced break
4162
+ * follows them. An engine strips a line's trailing white space, which is
4163
+ * right for the collapsible spaces CSS removes there, and wrong for these.
4164
+ */
4165
+ function keptAtEnd(natural, runs, starts, spans, fonts) {
4166
+ const boxAt = spans.boxAt;
4167
+ if (!boxAt || !runs.length)
4168
+ return { found: null, forced: false };
4169
+ const charAt = (offset) => {
4170
+ const k = runIndexAt(starts, offset);
4171
+ return runs[k].text.charCodeAt(offset - starts[k]);
4172
+ };
4173
+ let end = natural.end;
4174
+ let forced = false;
4175
+ if (end > natural.start && charAt(end - 1) === 10) {
4176
+ forced = true;
4177
+ end -= 1;
4178
+ }
4179
+ let found = null;
4180
+ let kept = false;
4181
+ for (let at = end - 1; at >= natural.start; at -= 1) {
4182
+ const c = charAt(at);
4183
+ if (isControl(c))
4184
+ continue;
4185
+ if (c !== 0x20)
4186
+ break;
4187
+ // a collapsible space after the last kept one is removed (4.1.3), and
4188
+ // one before a kept one hangs with it
4189
+ const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
4190
+ if (!keeps && !kept)
4191
+ continue;
4192
+ kept = true;
4193
+ const run = runs[runIndexAt(starts, at)];
4194
+ (found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
4195
+ }
4196
+ found?.reverse();
4197
+ return { found, forced };
4198
+ }
4199
+ /**
4200
+ * The spaces `pre-wrap` keeps that a text's line ends on, which the engine
4201
+ * hung (`keptAtEnd`): past the line's end, to its left where the layout
4202
+ * reads right to left, and their inline box's, whose background and border
4203
+ * cover them, as a browser draws them. Noted on the text (`LineText.hung`),
4204
+ * and their advance answered, with whether a forced break follows them.
4205
+ */
4206
+ function hangPreserved(text, runs, starts, fonts, rtl) {
4207
+ const natural = text.layout.lines[text.layoutLine];
4208
+ if (!natural)
4209
+ return { total: 0, forced: false };
4210
+ const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
4211
+ if (!found)
4212
+ return { total: 0, forced };
4213
+ text.hung = found;
4214
+ placeHung(text, rtl);
4215
+ let total = 0;
4216
+ for (const space of found)
4217
+ total += space.width;
4218
+ return { total, forced };
4219
+ }
4220
+ const NOTHING_HUNG = { total: 0, forced: false };
4221
+ /** Put a text's hung spaces beside its line's end, logical order outward:
4222
+ * to the right of its glyphs, or to their left. */
4223
+ function placeHung(text, left) {
4224
+ const natural = text.layout.lines[text.layoutLine];
4225
+ if (!text.hung || !natural)
4226
+ return;
4227
+ let x = left ? natural.x : natural.x + natural.width;
4228
+ for (const space of text.hung) {
4229
+ if (left)
4230
+ x -= space.width;
4231
+ space.x = x;
4232
+ if (!left)
4233
+ x += space.width;
4234
+ }
4235
+ }
4236
+ /**
4237
+ * The runs of a layout with the spaces `pre-wrap` keeps before a forced
4238
+ * break held where they fit (`keptAtEnd`) — a kept newline, or the block's
4239
+ * end after the last line where `ends` — or null where none are. They hang
4240
+ * there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
4241
+ * which the engine gives them when they are handed over as no-break
4242
+ * spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
4243
+ * line with them. Laid out again, the lines break where they did, since
4244
+ * the spaces fit where they are. A line of a layout cannot be moved over
4245
+ * for them afterwards: its lines are drawn in one draw, from one place.
4246
+ */
4247
+ function holdAtBreaks(layout, runs, spans, fonts, width, ends) {
4248
+ const starts = runStarts(runs);
4249
+ let held = null;
4250
+ const lines = layout.lines;
4251
+ for (let i = 0; i < lines.length; i += 1) {
4252
+ const natural = lines[i];
4253
+ const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
4254
+ if (!found || !(forced || (ends && i === lines.length - 1)))
4255
+ continue;
4256
+ let free = width - natural.width;
4257
+ for (const space of found) {
4258
+ if (space.width > free + 0.01)
4259
+ break;
4260
+ free -= space.width;
4261
+ (held ??= []).push(space.at);
4262
+ }
4263
+ }
4264
+ if (!held)
4265
+ return null;
4266
+ held.sort((a, b) => a - b);
4267
+ const out = runs.slice();
4268
+ let next = 0;
4269
+ for (let k = 0; k < runs.length && next < held.length; k += 1) {
4270
+ const start = starts[k];
4271
+ const end = start + runs[k].text.length;
4272
+ if (held[next] >= end)
4273
+ continue;
4274
+ const chars = runs[k].text.split('');
4275
+ while (next < held.length && held[next] < end) {
4276
+ chars[held[next] - start] = '\u00a0';
4277
+ next += 1;
4278
+ }
4279
+ out[k] = { ...runs[k], text: chars.join('') };
4280
+ }
4281
+ return out;
4282
+ }
4283
+ /** The spaces `pre-wrap` keeps that the lines one layout made hung at
4284
+ * their ends (`hangPreserved`), noted on each line's text. */
4285
+ function hangLines(lines, from, runs, fonts, rtl) {
4286
+ let starts = null;
4287
+ for (let i = from; i < lines.length; i += 1) {
4288
+ const text = lines[i].texts[0];
4289
+ if (!text)
4290
+ continue;
4291
+ starts ??= runStarts(runs);
4292
+ hangPreserved(text, runs, starts, fonts, rtl);
4293
+ }
4294
+ }
3026
4295
  /**
3027
4296
  * A clamped paragraph with an ellipsis at the end of its last line (CSS
3028
4297
  * Overflow 4, 4.2): its text up to where that line breaks in the room the
@@ -3249,14 +4518,17 @@ function unwrappedPlacer(style, width, last = true) {
3249
4518
  }
3250
4519
  /**
3251
4520
  * The spacing a space is given to be laid out as one that may be spaced
3252
- * apart, too little to move a glyph. It is not nothing, because neither
3253
- * engine measures a space that is a spaced run of its own as it measures
3254
- * the same space inside its run: ntk shapes each run apart, so the kerning
3255
- * pair a space made with the letter beside it is gone (Arial's `A` and
3256
- * `T` have them), and CoreText spaces a glyph with its kerning attribute,
3257
- * which takes the place of the font's pairs. A line justified from a
3258
- * measure of its spaces as they were came out wider than its box and
3259
- * broke a word early: on macOS, in Helvetica, half of a paragraph did.
4521
+ * apart, too little to move a glyph. It is not nothing, because CoreText
4522
+ * does not measure a space that is a spaced run of its own as it measures
4523
+ * the same space inside its run: it spaces a glyph with its kerning
4524
+ * attribute, which takes the place of the font's pairs. A line justified
4525
+ * from a measure of its spaces as they were came out wider than its box
4526
+ * and broke a word early: on macOS, in Helvetica, half of a paragraph did.
4527
+ * ntk keeps the pairs a spaced space makes with the letters beside it
4528
+ * (Arial's space and `T`, its `L` and space) where the run is marked
4529
+ * `kernAcross` — spacing that justifies a line is in addition to kerning
4530
+ * (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
4531
+ * a browser's is.
3260
4532
  */
3261
4533
  const HAIR = 1e-6;
3262
4534
  /** The runs with each space a run of its own, spaced a hair apart. */
@@ -3275,6 +4547,7 @@ function spacedApart(runs) {
3275
4547
  ...run,
3276
4548
  text: text[at],
3277
4549
  letterSpacing: (run.letterSpacing ?? 0) + HAIR,
4550
+ kernAcross: true,
3278
4551
  });
3279
4552
  piece = at + 1;
3280
4553
  }
@@ -3349,6 +4622,7 @@ function widenedAt(runs, extra) {
3349
4622
  ...run,
3350
4623
  text: text[at],
3351
4624
  letterSpacing: (run.letterSpacing ?? 0) + add,
4625
+ kernAcross: true,
3352
4626
  });
3353
4627
  piece = at + 1;
3354
4628
  }
@@ -3595,8 +4869,8 @@ function naturalLineHeight(fonts, style) {
3595
4869
  }
3596
4870
  /**
3597
4871
  * A block's strut: the ascent and descent of a line of nothing in its face,
3598
- * the half-leading its `line-height` adds shared above and below, as the
3599
- * text engines set a line (CSS 2.1 10.8.1).
4872
+ * the leading its `line-height` adds shared above and below as
4873
+ * `ascentOnLine` shares it (CSS 2.1 10.8.1).
3600
4874
  */
3601
4875
  export function strutOf(fonts, style) {
3602
4876
  // text set at no size takes no room, and CoreText reads a size of 0 as
@@ -3609,8 +4883,57 @@ export function strutOf(fonts, style) {
3609
4883
  : style.lineHeightIsLength
3610
4884
  ? style.lineHeight
3611
4885
  : style.lineHeight * style.fontSize;
3612
- const half = (target - face.ascent - face.descent) / 2;
3613
- return { ascent: face.ascent + half, descent: face.descent + half };
4886
+ const ascent = ascentOnLine(face.ascent, face.descent, target);
4887
+ return { ascent, descent: target - ascent };
4888
+ }
4889
+ /**
4890
+ * How far above its baseline an inline box's room on a line reaches: its
4891
+ * face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
4892
+ * rounded down to a whole pixel, the rest of the leading going below.
4893
+ *
4894
+ * The spec's halves are exact, and where one is not a whole pixel which
4895
+ * side of the grid the text lands on is the user agent's. Blink rounds the
4896
+ * half above down (`CalculateLeadingSpace`, core/layout/inline/
4897
+ * line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
4898
+ * 15 and 2px under. Split evenly, that box reached half a pixel above the
4899
+ * 12px strut beside it, whose 4px of leading divides, and every such line
4900
+ * came out 18.5px tall.
4901
+ */
4902
+ export function ascentOnLine(ascent, descent, height) {
4903
+ return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
4904
+ }
4905
+ /**
4906
+ * How far above its baseline a text engine's line reaches, its leading
4907
+ * shared as `ascentOnLine` shares it. The engines split it evenly (the
4908
+ * contract every text engine owes, a baseline `(height - ascent -
4909
+ * descent) / 2 + ascent` down), so a line on its own sits up to a pixel
4910
+ * lower than a browser's; one that did not say how tall its face is keeps
4911
+ * the engine's.
4912
+ */
4913
+ function lineAscent(natural) {
4914
+ const { ascent, descent } = natural;
4915
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
4916
+ return natural.baseline - natural.y;
4917
+ }
4918
+ return ascentOnLine(ascent, descent, natural.height);
4919
+ }
4920
+ /**
4921
+ * How far up a layout drawn whole moves for each of its lines' baselines
4922
+ * to be where `lineAscent` puts it. A layout is drawn in one batch, from
4923
+ * one origin, so it moves by one amount or not at all: where its lines
4924
+ * would move by different amounts — a face of other metrics on one — it
4925
+ * keeps the engine's baselines, which are less than a pixel out.
4926
+ */
4927
+ function layoutLift(layout) {
4928
+ let lift = NaN;
4929
+ for (const natural of layout.lines) {
4930
+ const own = natural.baseline - natural.y - lineAscent(natural);
4931
+ if (Number.isNaN(lift))
4932
+ lift = own;
4933
+ else if (Math.abs(own - lift) > 1e-6)
4934
+ return 0;
4935
+ }
4936
+ return Number.isNaN(lift) ? 0 : lift;
3614
4937
  }
3615
4938
  /**
3616
4939
  * The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
@@ -3786,12 +5109,18 @@ const NO_LIFT = { raise: 0, edge: null, lead: false };
3786
5109
  * and size is past at once, and any other answer is kept on its style.
3787
5110
  */
3788
5111
  function ownsLeading(fonts, block, style) {
3789
- // a bold or an italic face keeps its family's line metrics, and a
3790
- // paragraph is thick with <strong>, <em> and <a>
5112
+ // the block's own face is past at once. A bold or an italic one is not:
5113
+ // a paragraph is thick with <strong>, <em> and <a>, and most families'
5114
+ // faces share their line metrics, but not every one's — Helvetica Neue
5115
+ // Bold reaches 0.975em above its baseline to the regular's 0.952, and
5116
+ // Blink, which rounds each to a whole pixel, sets a 12px bold word on a
5117
+ // 19.2px line a pixel taller. It is measured once for its style, below.
3791
5118
  if (style.lineHeight === block.lineHeight &&
3792
5119
  style.lineHeightIsLength === block.lineHeightIsLength &&
3793
5120
  style.fontSize === block.fontSize &&
3794
- style.fontFamily === block.fontFamily) {
5121
+ style.fontFamily === block.fontFamily &&
5122
+ style.fontWeight === block.fontWeight &&
5123
+ style.fontStyle === block.fontStyle) {
3795
5124
  return false;
3796
5125
  }
3797
5126
  const known = LEADS.get(style);
@@ -3904,7 +5233,7 @@ function tooNarrow(runs, end, width, room) {
3904
5233
  }
3905
5234
  if (WORD_CHAR.test(before) && WORD_CHAR.test(after))
3906
5235
  return true;
3907
- return width > room + 0.5 && before !== '';
5236
+ return width > room + FIT_SLACK && before !== '';
3908
5237
  }
3909
5238
  const WORD_CHAR = /^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
3910
5239
  function bandAt(options, y, height) {