@react-x11/components 0.11.0 → 0.13.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 (126) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +38 -10
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +43 -58
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts +134 -20
  10. package/dist/html/css/cascade.d.ts.map +1 -1
  11. package/dist/html/css/cascade.js +538 -128
  12. package/dist/html/css/cascade.js.map +1 -1
  13. package/dist/html/css/color.d.ts +19 -0
  14. package/dist/html/css/color.d.ts.map +1 -1
  15. package/dist/html/css/color.js +86 -0
  16. package/dist/html/css/color.js.map +1 -1
  17. package/dist/html/css/parse.d.ts +40 -2
  18. package/dist/html/css/parse.d.ts.map +1 -1
  19. package/dist/html/css/parse.js +391 -13
  20. package/dist/html/css/parse.js.map +1 -1
  21. package/dist/html/css/style.d.ts +52 -1
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +328 -11
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -7
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +10 -1
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js +27 -2
  32. package/dist/html/css/values.js.map +1 -1
  33. package/dist/html/css/vars.d.ts +5 -0
  34. package/dist/html/css/vars.d.ts.map +1 -1
  35. package/dist/html/css/vars.js +2 -2
  36. package/dist/html/css/vars.js.map +1 -1
  37. package/dist/html/dom.d.ts.map +1 -1
  38. package/dist/html/dom.js +38 -3
  39. package/dist/html/dom.js.map +1 -1
  40. package/dist/html/fonts.d.ts +25 -1
  41. package/dist/html/fonts.d.ts.map +1 -1
  42. package/dist/html/fonts.js +99 -6
  43. package/dist/html/fonts.js.map +1 -1
  44. package/dist/html/form.d.ts +200 -0
  45. package/dist/html/form.d.ts.map +1 -0
  46. package/dist/html/form.js +801 -0
  47. package/dist/html/form.js.map +1 -0
  48. package/dist/html/index.d.ts +12 -0
  49. package/dist/html/index.d.ts.map +1 -1
  50. package/dist/html/index.js +30 -264
  51. package/dist/html/index.js.map +1 -1
  52. package/dist/html/layout/block.d.ts +11 -2
  53. package/dist/html/layout/block.d.ts.map +1 -1
  54. package/dist/html/layout/block.js +186 -19
  55. package/dist/html/layout/block.js.map +1 -1
  56. package/dist/html/layout/boxes.d.ts +65 -7
  57. package/dist/html/layout/boxes.d.ts.map +1 -1
  58. package/dist/html/layout/boxes.js +87 -22
  59. package/dist/html/layout/boxes.js.map +1 -1
  60. package/dist/html/layout/cache.d.ts +2 -2
  61. package/dist/html/layout/cache.d.ts.map +1 -1
  62. package/dist/html/layout/cache.js +8 -3
  63. package/dist/html/layout/cache.js.map +1 -1
  64. package/dist/html/layout/flex.js +4 -1
  65. package/dist/html/layout/flex.js.map +1 -1
  66. package/dist/html/layout/floats.d.ts +4 -0
  67. package/dist/html/layout/floats.d.ts.map +1 -1
  68. package/dist/html/layout/floats.js +26 -0
  69. package/dist/html/layout/floats.js.map +1 -1
  70. package/dist/html/layout/inline.d.ts +26 -2
  71. package/dist/html/layout/inline.d.ts.map +1 -1
  72. package/dist/html/layout/inline.js +1232 -115
  73. package/dist/html/layout/inline.js.map +1 -1
  74. package/dist/html/node.d.ts +120 -15
  75. package/dist/html/node.d.ts.map +1 -1
  76. package/dist/html/node.js +993 -174
  77. package/dist/html/node.js.map +1 -1
  78. package/dist/html/paint.d.ts +75 -2
  79. package/dist/html/paint.d.ts.map +1 -1
  80. package/dist/html/paint.js +836 -200
  81. package/dist/html/paint.js.map +1 -1
  82. package/dist/html/surfaces.d.ts +7 -2
  83. package/dist/html/surfaces.d.ts.map +1 -1
  84. package/dist/html/surfaces.js +16 -0
  85. package/dist/html/surfaces.js.map +1 -1
  86. package/dist/html/widgets.d.ts +29 -0
  87. package/dist/html/widgets.d.ts.map +1 -0
  88. package/dist/html/widgets.js +620 -0
  89. package/dist/html/widgets.js.map +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal/window.d.ts +22 -0
  94. package/dist/internal/window.d.ts.map +1 -1
  95. package/dist/internal/window.js +35 -8
  96. package/dist/internal/window.js.map +1 -1
  97. package/dist/richtext/node.d.ts +7 -0
  98. package/dist/richtext/node.d.ts.map +1 -1
  99. package/dist/richtext/node.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/flow/node.ts +80 -64
  102. package/src/html/controls.ts +79 -58
  103. package/src/html/css/cascade.ts +610 -146
  104. package/src/html/css/color.ts +87 -0
  105. package/src/html/css/parse.ts +431 -13
  106. package/src/html/css/style.ts +367 -11
  107. package/src/html/css/ua.ts +76 -8
  108. package/src/html/css/values.ts +31 -3
  109. package/src/html/css/vars.ts +2 -2
  110. package/src/html/dom.ts +41 -3
  111. package/src/html/fonts.ts +116 -6
  112. package/src/html/form.ts +962 -0
  113. package/src/html/index.ts +49 -300
  114. package/src/html/layout/block.ts +231 -16
  115. package/src/html/layout/boxes.ts +133 -32
  116. package/src/html/layout/cache.ts +8 -3
  117. package/src/html/layout/flex.ts +4 -1
  118. package/src/html/layout/floats.ts +24 -0
  119. package/src/html/layout/inline.ts +1445 -108
  120. package/src/html/node.ts +1010 -166
  121. package/src/html/paint.ts +980 -270
  122. package/src/html/surfaces.ts +21 -1
  123. package/src/html/widgets.ts +735 -0
  124. package/src/index.ts +1 -0
  125. package/src/internal/window.ts +36 -8
  126. package/src/richtext/node.ts +7 -0
@@ -30,6 +30,9 @@
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
32
 
33
+ import bidiModule from 'bidi-js';
34
+ import type { Bidi } from 'bidi-js';
35
+
33
36
  import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
34
37
  import type { TextRun } from '../../richtext/index.js';
35
38
  import type { ComputedStyle } from '../css/style.js';
@@ -42,6 +45,7 @@ import { inkColor, isTransparent, resolve } from '../css/values.js';
42
45
  import {
43
46
  BOX_RAISES,
44
47
  LINE_BOX_RAISES,
48
+ PADDED_FACES,
45
49
  isOffset,
46
50
  letteredAfter,
47
51
  SHADOWED_TEXT,
@@ -62,6 +66,18 @@ import type {
62
66
  import type { FloatContext } from './floats.js';
63
67
  import { tableGrid } from './grid.js';
64
68
 
69
+ /** How a paragraph's lines are fitted to their width, and measured: as a
70
+ * browser fits them (`FontsLike`'s `fit`). */
71
+ const PARAGRAPH_FIT = 'items' as const;
72
+
73
+ /** How far past the room a line's content may reach and still fit it: a
74
+ * 64th of a pixel, the epsilon Blink's line breaker adds to the width it
75
+ * fits to (`LineBreaker::AvailableWidthToFit`), and the one the engine
76
+ * fits a paragraph's lines with. A line composed a piece at a time asks
77
+ * the same question of each piece, and half a pixel let a word run a
78
+ * third of one past the line a browser breaks before it. */
79
+ const FIT_SLACK = 1 / 64;
80
+
65
81
  /** The slice of ntk's font manager this needs. Structural, as everywhere. */
66
82
  export interface FontsLike {
67
83
  layout(
@@ -82,6 +98,13 @@ export interface FontsLike {
82
98
  /** Whether soft wraps are made at all: false is a line to each
83
99
  * forced break, cut at `maxWidth` where `overflow` says so. ntk's. */
84
100
  wrap?: boolean;
101
+ /** `'items'` fits a line as a browser does, each run's part of it
102
+ * rounded up to a 64th of a pixel before it is added, as Blink
103
+ * rounds an element's text on a line up to a LayoutUnit — and
104
+ * reports a line as wide as that, so a box as wide as its text
105
+ * holds it. What a paragraph's lines are laid out with; ntk's, from
106
+ * 8.16.1, which an engine without it leaves at the advances' sum. */
107
+ fit?: 'items';
85
108
  },
86
109
  ): TextLayoutLike;
87
110
  match(
@@ -110,6 +133,22 @@ const LAYOUT_RUNS = new WeakMap<TextLayoutLike, TextRun[] | number[]>();
110
133
 
111
134
  const WIDEST_WORD = new WeakMap<TextLayoutLike, number>();
112
135
 
136
+ /**
137
+ * A fragment of text that runs on from the text before it on its line, with
138
+ * no place to break between the two — only inline boxes' edges, or a float
139
+ * — and how wide those edges are: a word across a `<span>` with padding is
140
+ * in two fragments, and as wide as both and the padding between. What
141
+ * `joinsBefore` answers, for a min-content bound (`block.ts`'s `wordBound`)
142
+ * that would have the word no wider than either fragment's widest.
143
+ */
144
+ const JOINS = new WeakMap<LineText, number>();
145
+
146
+ /** The edges a fragment of text runs on across from the text before it on
147
+ * its line, or undefined where it does not run on from it (`JOINS`). */
148
+ export function joinsBefore(text: LineText): number | undefined {
149
+ return JOINS.get(text);
150
+ }
151
+
113
152
  /**
114
153
  * The widest word between spaces in a layout's text: its runs laid out
115
154
  * again with every space a line break, at no width limit. Where a text's
@@ -137,7 +176,9 @@ export function widestWord(
137
176
  text: run.text.replace(/[ \t\u200b]+/g, '\n'),
138
177
  })),
139
178
  {},
140
- {},
179
+ // measured as the lines are fitted, or a word across two elements is
180
+ // read narrower than it fits
181
+ { fit: PARAGRAPH_FIT },
141
182
  );
142
183
  widest = 0;
143
184
  for (const line of words.lines) widest = Math.max(widest, line.width);
@@ -444,10 +485,14 @@ function linesOf(
444
485
  // searched for one where it cannot be
445
486
  let tabbed = false;
446
487
  let shadowed = false;
488
+ // and text that keeps its spaces where its lines wrap, whose spaces a
489
+ // line may end on (`hangPreserved`)
490
+ let keepsSpaces = false;
447
491
  for (const item of items) {
448
492
  if (item.kind !== 'text' || item.control) continue;
449
493
  const style = item.box.style;
450
494
  if (style.textShadow) shadowed = true;
495
+ if (style.whiteSpace === 'pre-wrap') keepsSpaces = true;
451
496
  if (
452
497
  !tabbed &&
453
498
  (style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
@@ -482,6 +527,9 @@ function linesOf(
482
527
 
483
528
  const style = block.style;
484
529
  const wrapWords = overflowWrapOf(style, items);
530
+ // a line may break between any two letters, an edge between them or not
531
+ const breaksAnywhere =
532
+ style.wordBreak === 'break-all' || style.lineBreakAnywhere;
485
533
  const base = {
486
534
  family: style.fontFamily,
487
535
  size: style.fontSize,
@@ -564,6 +612,7 @@ function linesOf(
564
612
  lineHeightMul,
565
613
  align,
566
614
  fonts,
615
+ keepsSpaces,
567
616
  );
568
617
  if (laid && !shortOfStrut(laid.lines, strut, firstStrut)) return laid;
569
618
  if (laid) strutted = true;
@@ -608,6 +657,7 @@ function linesOf(
608
657
  align,
609
658
  fonts,
610
659
  { chars: Infinity, hardLines: 1, cut },
660
+ keepsSpaces,
611
661
  );
612
662
  }
613
663
  if (perLine) cut = null;
@@ -620,6 +670,8 @@ function linesOf(
620
670
  lineHeightMul,
621
671
  align,
622
672
  fonts,
673
+ CHUNKS,
674
+ keepsSpaces,
623
675
  );
624
676
  if (!shortOfStrut(chunked.lines, strut, firstStrut)) return chunked;
625
677
  strutted = true;
@@ -646,10 +698,26 @@ function linesOf(
646
698
  align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
647
699
  direction: style.direction,
648
700
  overflowWrap: cutWrap(style, cut, wrapWords),
701
+ fit: PARAGRAPH_FIT,
649
702
  ...cut,
650
703
  };
651
704
  let layout = fonts.layout(runs, base, layoutOptions);
652
705
  LAYOUT_RUNS.set(layout, runs);
706
+ const held = keepsSpaces
707
+ ? holdAtBreaks(
708
+ layout,
709
+ runs,
710
+ spans,
711
+ fonts,
712
+ layoutOptions.maxWidth ?? options.width,
713
+ !cut,
714
+ )
715
+ : null;
716
+ if (held) {
717
+ runs = held;
718
+ layout = fonts.layout(runs, base, layoutOptions);
719
+ LAYOUT_RUNS.set(layout, runs);
720
+ }
653
721
  // A clamp that cut the text short says so, for the clamp point after
654
722
  // it, and its last line ends in an ellipsis — as it does where the
655
723
  // clamp point falls just after the lines, more of the container after
@@ -724,6 +792,7 @@ function linesOf(
724
792
  ? unwrappedPlacer(style, options.width, false)
725
793
  : null,
726
794
  );
795
+ if (keepsSpaces) hangLines(lines, 0, runs, fonts, rtl);
727
796
  // a clamp and a cut are the one layout's to make
728
797
  if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
729
798
  strutted = true;
@@ -735,6 +804,10 @@ function linesOf(
735
804
  }
736
805
 
737
806
  // --- the general case: line at a time -------------------------------------
807
+ // the paragraph's bidi levels, where anything in it can be reordered: a
808
+ // line is laid out a piece at a time here, and its pieces are ordered by
809
+ // them (`levelPieces`)
810
+ const paraBidi = fonts ? paragraphBidi(block, items, rtl) : null;
738
811
  const lines: LineBox[] = [];
739
812
  let widest = 0;
740
813
  let y = 0;
@@ -796,6 +869,7 @@ function linesOf(
796
869
  align: 'left',
797
870
  direction: style.direction,
798
871
  overflowWrap: wrapWords,
872
+ fit: PARAGRAPH_FIT,
799
873
  },
800
874
  }
801
875
  : null;
@@ -814,6 +888,14 @@ function linesOf(
814
888
  * which `text-align-last` aligns. */
815
889
  const close = (last = false): void => {
816
890
  const onFirst = lineOne !== null && !lines.length;
891
+ const levels: LineLevels | null = paraBidi
892
+ ? {
893
+ bidi: paraBidi,
894
+ fonts: fonts!,
895
+ base: onFirst ? lineOne.base : base,
896
+ lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
897
+ }
898
+ : null;
817
899
  const line = finishLine(
818
900
  open,
819
901
  y,
@@ -831,12 +913,23 @@ function linesOf(
831
913
  ? lineOne.justify
832
914
  : lineJustify
833
915
  : null,
916
+ levels,
834
917
  );
835
918
  if (!line) {
836
919
  open = openLine(0);
837
920
  placeDeferred();
838
921
  return;
839
922
  }
923
+ // A line with no text on it — a control, an image or an inline-block
924
+ // that wrapped on its own — stands where the text around it does: an
925
+ // empty range at the end of the line before, not at 0. What finds a
926
+ // line by its text binary-searches these ranges as sorted
927
+ // (`lineBands`, `caretAt`), and a textless line at 0 after a line of
928
+ // text sent the search past every line: a `<label>` on the line
929
+ // before a submit button that wrapped measured as no box at all.
930
+ if (!line.texts.length && lines.length) {
931
+ line.textStart = line.textEnd = lines[lines.length - 1].textEnd;
932
+ }
840
933
  lines.push(line);
841
934
  widest = Math.max(widest, line.width);
842
935
  y += line.height;
@@ -958,7 +1051,12 @@ function linesOf(
958
1051
  const raise = atomicRaise(fonts, box, (box.parent ?? block).style);
959
1052
  if (raise) placed.raise = raise;
960
1053
  open.atomics.push(placed);
961
- open.order.push({ kind: 'atomic', at: open.x, item: placed });
1054
+ open.order.push({
1055
+ kind: 'atomic',
1056
+ at: open.x,
1057
+ item: placed,
1058
+ ...(paraBidi ? { para: paraBidi.starts[index] } : null),
1059
+ });
962
1060
  open.x += outer;
963
1061
  open.hang = 0;
964
1062
  open.left = room.left;
@@ -1002,7 +1100,10 @@ function linesOf(
1002
1100
  !open.edges.length &&
1003
1101
  !pending.length &&
1004
1102
  !deferred.length &&
1005
- !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
1103
+ !(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
1104
+ // and ordered alone as the paragraph orders it
1105
+ (!paraBidi ||
1106
+ orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
1006
1107
  if (tailIsPlain) {
1007
1108
  const tailOptions = {
1008
1109
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
@@ -1010,14 +1111,31 @@ function linesOf(
1010
1111
  align,
1011
1112
  direction: style.direction,
1012
1113
  overflowWrap: wrapWords,
1114
+ fit: PARAGRAPH_FIT,
1013
1115
  };
1014
- let layout = fonts.layout(segment.runs, base, tailOptions);
1015
- LAYOUT_RUNS.set(layout, segment.runs);
1116
+ let tailRuns = segment.runs;
1117
+ let layout = fonts.layout(tailRuns, base, tailOptions);
1118
+ LAYOUT_RUNS.set(layout, tailRuns);
1119
+ const held = keepsSpaces
1120
+ ? holdAtBreaks(
1121
+ layout,
1122
+ tailRuns,
1123
+ segment.spans,
1124
+ fonts,
1125
+ tailOptions.maxWidth ?? options.width,
1126
+ true,
1127
+ )
1128
+ : null;
1129
+ if (held) {
1130
+ tailRuns = held;
1131
+ layout = fonts.layout(tailRuns, base, tailOptions);
1132
+ LAYOUT_RUNS.set(layout, tailRuns);
1133
+ }
1016
1134
  // the lines past the floats are justified as the lines beside them
1017
1135
  // were (`finishLine`), in the room they have
1018
1136
  layout = justifiedLayout(
1019
1137
  fonts,
1020
- segment.runs,
1138
+ tailRuns,
1021
1139
  base,
1022
1140
  tailOptions,
1023
1141
  layout,
@@ -1025,13 +1143,14 @@ function linesOf(
1025
1143
  justify,
1026
1144
  ).layout;
1027
1145
  const tail: LineBox[] = [];
1146
+ const lift = layoutLift(layout);
1028
1147
  for (let i = 0; i < layout.lines.length; i += 1) {
1029
1148
  const natural = layout.lines[i];
1030
1149
  const text: LineText = {
1031
1150
  layout,
1032
1151
  layoutLine: i,
1033
1152
  drawX: band.left,
1034
- drawY: y,
1153
+ drawY: y - lift,
1035
1154
  textStart: segment.spans.documentAt(natural.start),
1036
1155
  textEnd: segment.spans.documentAt(natural.end),
1037
1156
  layoutStart: natural.start,
@@ -1043,13 +1162,14 @@ function linesOf(
1043
1162
  y: y + natural.y,
1044
1163
  width: natural.width,
1045
1164
  height: natural.height,
1046
- baseline: natural.baseline - natural.y,
1165
+ baseline: natural.baseline - natural.y - lift,
1047
1166
  texts: [text],
1048
1167
  textStart: text.textStart,
1049
1168
  textEnd: text.textEnd,
1050
1169
  atomics: [],
1051
1170
  });
1052
1171
  }
1172
+ if (keepsSpaces) hangLines(tail, 0, tailRuns, fonts, rtl);
1053
1173
  // or a line at a time, where one of them is shorter than the strut
1054
1174
  if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
1055
1175
  for (const line of tail) {
@@ -1082,27 +1202,104 @@ function linesOf(
1082
1202
  !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
1083
1203
  ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
1084
1204
  : 0;
1085
- const fragment = fonts.layout(segment.runs, lineBase, {
1086
- maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
1087
- lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
1088
- // Never aligned by the text layout: the alignment belongs to the whole
1089
- // line — its text, its atomics and its inline boxes' edges together —
1090
- // which only this loop can see, and `finishLine` shifts it all. Laid
1091
- // out aligned, the first fragment of a centred line was centred alone
1092
- // and whatever followed it on the line was placed as though it had
1093
- // not been, over it.
1094
- align: 'left',
1095
- direction: style.direction,
1096
- overflowWrap: wrapWords,
1097
- maxLines: 1,
1098
- });
1099
- LAYOUT_RUNS.set(fragment, segment.runs);
1100
- const first = fragment.lines[0];
1205
+ const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
1206
+ let fragment = fragmentLayout(
1207
+ fonts,
1208
+ segment.runs,
1209
+ lineBase,
1210
+ style,
1211
+ lineHeight,
1212
+ wrapWords,
1213
+ room - tied,
1214
+ );
1215
+ let first = fragment.lines[0];
1101
1216
  if (!first) {
1102
1217
  index = segment.nextIndex;
1103
1218
  offset = 0;
1104
1219
  continue;
1105
1220
  }
1221
+ // Did the segment wrap? ntk answers with `truncated`. A layout that does
1222
+ // not carry the flag — react-x11's Cocoa engine reports none — is asked
1223
+ // the same of its line ends instead: it wrapped if anything but
1224
+ // whitespace follows the first line. Read as "fitted", a cut fragment
1225
+ // advanced past the whole segment, and a paragraph beside a float lost
1226
+ // every line after its first. And a line that ends at a `<br>` is over
1227
+ // whether or not anything followed it in this segment: what comes next
1228
+ // — an atomic, an element's edge — is the next line's.
1229
+ let wrapped =
1230
+ breakBefore(segment.runs, first.end) ||
1231
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1232
+ // An inline box's edge is no place to break a line (CSS Text 3, 5.1):
1233
+ // where the segment runs to its end on this line, its last word runs on
1234
+ // past it, through the edges after it, into the text they stand before
1235
+ // — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
1236
+ // without the padding. Where the line may break at that text after all,
1237
+ // the edges that close boxes are still the word's, as a box's end goes
1238
+ // with the content it closes, and it is the ones that open a box that
1239
+ // go with what follows. The line holds the word and all of that or none
1240
+ // of it, so where it does not fit, the line breaks at the last place in
1241
+ // the segment it may break, and the word goes to the next line whole.
1242
+ // Where the segment has no such place, the word is the line's first,
1243
+ // and is measured with what it runs into (below). A word that ends in
1244
+ // white space hangs it, and its edges after it with it.
1245
+ let glued = 0;
1246
+ if (
1247
+ !wrapped &&
1248
+ wraps(style) &&
1249
+ lineItems[segment.nextIndex]?.kind === 'edge' &&
1250
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
1251
+ // none of it matters where the most it could come to fits: most
1252
+ // edges are nowhere near a line's end
1253
+ first.width + unbreakableBound(lineItems, segment.nextIndex) >
1254
+ room + FIT_SLACK
1255
+ ) {
1256
+ const after = textAfterEdges(lineItems, segment.nextIndex);
1257
+ glued =
1258
+ after &&
1259
+ !breaksBetween(
1260
+ fonts,
1261
+ lineBase,
1262
+ segment.runs,
1263
+ runsLength(segment.runs),
1264
+ after,
1265
+ breaksAnywhere,
1266
+ )
1267
+ ? unbreakableAfter(
1268
+ lineItems,
1269
+ segment.nextIndex,
1270
+ style,
1271
+ fonts,
1272
+ lineBase,
1273
+ )
1274
+ : closingEdges(lineItems, segment.nextIndex);
1275
+ }
1276
+ if (glued > 0 && first.width + glued > room + FIT_SLACK) {
1277
+ // the last place it may break: laid out a hair narrower than it is,
1278
+ // which the rest of the segment fits, not in the room the word
1279
+ // leaves, where it broke after its first word if the word and what
1280
+ // it runs into are wider than the line
1281
+ const earlier = fragmentLayout(
1282
+ fonts,
1283
+ segment.runs,
1284
+ lineBase,
1285
+ style,
1286
+ lineHeight,
1287
+ wrapWords,
1288
+ first.width - 0.5,
1289
+ );
1290
+ const line = earlier.lines[0];
1291
+ // and not inside a word that `overflow-wrap` cut to fit: that is for
1292
+ // a word no line holds, and the word and what it runs into may fit
1293
+ // the next line
1294
+ if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
1295
+ fragment = earlier;
1296
+ first = line;
1297
+ wrapped = true;
1298
+ glued = 0;
1299
+ }
1300
+ }
1301
+ // the first word, with what it runs into past the segment
1302
+ const word = first.width + glued;
1106
1303
  // Nothing fits beside the floats — the first word is wider than the
1107
1304
  // room they leave, and was either run past it or cut inside itself to
1108
1305
  // fit — so the line moves down to where a float ends, and tries again
@@ -1112,7 +1309,7 @@ function linesOf(
1112
1309
  if (
1113
1310
  isEmpty(open) &&
1114
1311
  available < options.width &&
1115
- tooNarrow(segment.runs, first.end, first.width, room)
1312
+ tooNarrow(segment.runs, first.end, word, room)
1116
1313
  ) {
1117
1314
  const below = belowFloats(options, y, guess);
1118
1315
  if (below !== null) {
@@ -1122,30 +1319,47 @@ function linesOf(
1122
1319
  }
1123
1320
  // An inline box's opening edge goes with the first word after it: where
1124
1321
  // the word does not fit the room the edge leaves, both go to the next
1125
- // line, rather than the edge being left at this one's end. And a word
1126
- // that may start a line — after a space the line ends on, or after an
1127
- // atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
1128
- // next line whole where it does not fit what is left of this one,
1129
- // rather than being broken inside itself to fit it: the text after an
1130
- // inline-block, or after a float it was cut at, was its first letter
1131
- // at the line's end and the rest on the next.
1322
+ // line, rather than the edge being left at this one's end — where the
1323
+ // line may break there at all. The edge is no place to break (CSS Text
1324
+ // 3, 5.1), so that is where the line may break between what it ends on
1325
+ // and the word: after white space or an atomic, or where the text
1326
+ // engine breaks the two run together (`breaksBetween`). `ab<span
1327
+ // style="padding: 0 4px">cd</span>` is one word, and a line too narrow
1328
+ // for it runs past its end, as it does for the word without the
1329
+ // padding; broken before the span, a float's min-content width was a
1330
+ // letter wide, and it came out as wide as its room.
1331
+ // And a word that may start a line — after a space the line ends on,
1332
+ // or after an atomic, which has a break after it (CSS Text 3, 5.1) —
1333
+ // goes to the next line whole where it does not fit what is left of
1334
+ // this one, rather than being broken inside itself to fit it: the text
1335
+ // after an inline-block, or after a float it was cut at, was its first
1336
+ // letter at the line's end and the rest on the next.
1132
1337
  const startsLine =
1133
1338
  wraps(style) &&
1134
1339
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
1135
1340
  if (
1136
1341
  !isEmpty(open) &&
1137
1342
  (pendingWidth > 0
1138
- ? tooNarrow(segment.runs, first.end, first.width, room)
1343
+ ? tooNarrow(segment.runs, first.end, word, room) &&
1344
+ breaksAfterLine(
1345
+ open,
1346
+ style,
1347
+ fonts,
1348
+ lineBase,
1349
+ segment.runs,
1350
+ breaksAnywhere,
1351
+ )
1139
1352
  : startsLine &&
1140
1353
  // run past the room — its white space is no part of the width —
1141
1354
  // or cut inside itself to fit it
1142
- (first.width > room + 0.5 ||
1143
- tooNarrow(segment.runs, first.end, first.width, room)))
1355
+ (word > room + FIT_SLACK ||
1356
+ tooNarrow(segment.runs, first.end, word, room)))
1144
1357
  ) {
1145
1358
  close();
1146
1359
  continue;
1147
1360
  }
1148
1361
  placePending(band.left);
1362
+ const joins = joinsOnto(open, segment.runs, first.start);
1149
1363
  const placed: LineText = {
1150
1364
  layout: fragment,
1151
1365
  layoutLine: 0,
@@ -1158,28 +1372,35 @@ function linesOf(
1158
1372
  spans: segment.spans,
1159
1373
  };
1160
1374
  segment.spans.giveGaps(placed, first.start, first.end);
1375
+ if (joins !== undefined) JOINS.set(placed, joins);
1161
1376
  open.texts.push(placed);
1162
1377
  open.order.push({
1163
1378
  kind: 'text',
1164
1379
  at: open.x,
1165
1380
  item: placed,
1166
1381
  reads: readingOf(segment.runs, first.start, first.end),
1382
+ ...(paraBidi
1383
+ ? {
1384
+ from: {
1385
+ para: paraBidi.starts[index] + offset,
1386
+ runs: segment.runs,
1387
+ spans: segment.spans,
1388
+ },
1389
+ }
1390
+ : null),
1167
1391
  });
1168
1392
  open.x += first.width;
1169
1393
  open.left = band.left;
1170
1394
  open.hang = 0;
1395
+ // the spaces `pre-wrap` keeps that it ends on, which the engine hung
1396
+ const hung = keepsSpaces
1397
+ ? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
1398
+ : NOTHING_HUNG;
1399
+ /** Before a forced break, the ones that fit take room (4.1.3). */
1400
+ const holdHung = (): void => {
1401
+ open.x += Math.min(hung.total, Math.max(0, available - open.x));
1402
+ };
1171
1403
 
1172
- // Did the segment wrap? ntk answers with `truncated`. A layout that does
1173
- // not carry the flag — react-x11's Cocoa engine reports none — is asked
1174
- // the same of its line ends instead: it wrapped if anything but
1175
- // whitespace follows the first line. Read as "fitted", a cut fragment
1176
- // advanced past the whole segment, and a paragraph beside a float lost
1177
- // every line after its first. And a line that ends at a `<br>` is over
1178
- // whether or not anything followed it in this segment: what comes next
1179
- // — an atomic, an element's edge — is the next line's.
1180
- const wrapped =
1181
- breakBefore(segment.runs, first.end) ||
1182
- (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1183
1404
  if (!wrapped) {
1184
1405
  // It fitted: the cursor stays on this line for whatever comes next.
1185
1406
  //
@@ -1193,7 +1414,10 @@ function linesOf(
1193
1414
  const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
1194
1415
  let trailing = '';
1195
1416
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
1196
- const text = segment.runs[i].text;
1417
+ // through the bidi controls after them, which take no room: the
1418
+ // engine strips the spaces before one as well
1419
+ const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
1420
+ if (!text) continue;
1197
1421
  const m = hung.exec(text);
1198
1422
  if (!m) break;
1199
1423
  trailing = m[0] + trailing;
@@ -1201,18 +1425,26 @@ function linesOf(
1201
1425
  }
1202
1426
  if (trailing) {
1203
1427
  // and noted: if what follows goes to the next line after all,
1204
- // these spaces end this one, where CSS removes them (16.6.1)
1428
+ // these spaces end this one, where CSS removes them (16.6.1).
1429
+ // As wide as the engine says they make its line where the line
1430
+ // goes on, which a layout that fits as a browser does rounds with
1431
+ // the text they end, once (`advance`): the text rounded up to a
1432
+ // 64th and the spaces added after it came to more
1205
1433
  open.hang =
1206
- spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1207
- trailing.length;
1434
+ first.advance !== undefined
1435
+ ? first.advance - first.width
1436
+ : spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1437
+ trailing.length;
1208
1438
  open.x += open.hang;
1209
1439
  }
1210
- }
1440
+ } else if (hung.total) holdHung();
1211
1441
  index = segment.nextIndex;
1212
1442
  offset = 0;
1213
1443
  continue;
1214
1444
  }
1215
- close(breakBefore(segment.runs, first.end));
1445
+ const forced = breakBefore(segment.runs, first.end);
1446
+ if (forced && hung.total) holdHung();
1447
+ close(forced);
1216
1448
  const advanced = advance(items, index, offset, first.end);
1217
1449
  index = advanced.index;
1218
1450
  offset = advanced.offset;
@@ -1235,6 +1467,36 @@ function linesOf(
1235
1467
 
1236
1468
  const EMPTY: InlineResult = { lines: [], height: 0, width: 0 };
1237
1469
 
1470
+ /** The first line of a segment's text in a width, as the line at a time
1471
+ * lays one out (`linesOf`). */
1472
+ function fragmentLayout(
1473
+ fonts: FontsLike,
1474
+ runs: TextRun[],
1475
+ base: Record<string, unknown>,
1476
+ style: ComputedStyle,
1477
+ lineHeight: number,
1478
+ overflowWrap: 'normal' | 'break-word',
1479
+ width: number,
1480
+ ): TextLayoutLike {
1481
+ const laid = fonts.layout(runs, base, {
1482
+ maxWidth: wraps(style) ? Math.max(1, width) : undefined,
1483
+ lineHeight,
1484
+ // Never aligned by the text layout: the alignment belongs to the whole
1485
+ // line — its text, its atomics and its inline boxes' edges together —
1486
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1487
+ // out aligned, the first fragment of a centred line was centred alone
1488
+ // and whatever followed it on the line was placed as though it had not
1489
+ // been, over it.
1490
+ align: 'left',
1491
+ direction: style.direction,
1492
+ overflowWrap,
1493
+ fit: PARAGRAPH_FIT,
1494
+ maxLines: 1,
1495
+ });
1496
+ LAYOUT_RUNS.set(laid, runs);
1497
+ return laid;
1498
+ }
1499
+
1238
1500
  /**
1239
1501
  * Which line each float a paragraph placed stands beside: the one whose top
1240
1502
  * it went at, whose content moved over for it — added up on the line, for
@@ -1361,9 +1623,10 @@ function layoutSpaced(
1361
1623
  lineHeightMul: number,
1362
1624
  align: string,
1363
1625
  fonts: FontsLike,
1626
+ keepsSpaces: boolean,
1364
1627
  ): InlineResult | null {
1365
1628
  const wrapWords = overflowWrapOf(style, items);
1366
- const runs: TextRun[] = [];
1629
+ let runs: TextRun[] = [];
1367
1630
  const spans = new SpanMap();
1368
1631
  const spacers: { at: number; edge: Extract<Item, { kind: 'edge' }> }[] = [];
1369
1632
  let doc = 0;
@@ -1394,9 +1657,18 @@ function layoutSpaced(
1394
1657
  align,
1395
1658
  direction: style.direction,
1396
1659
  overflowWrap: wrapWords,
1660
+ fit: PARAGRAPH_FIT,
1397
1661
  };
1398
1662
  let layout = fonts.layout(runs, base, layoutOptions);
1399
1663
  LAYOUT_RUNS.set(layout, runs);
1664
+ const held = keepsSpaces
1665
+ ? holdAtBreaks(layout, runs, spans, fonts, width, true)
1666
+ : null;
1667
+ if (held) {
1668
+ runs = held;
1669
+ layout = fonts.layout(runs, base, layoutOptions);
1670
+ LAYOUT_RUNS.set(layout, runs);
1671
+ }
1400
1672
  // justified with its spacers in it, which are edges and not spaces
1401
1673
  layout = justifiedLayout(
1402
1674
  fonts,
@@ -1409,6 +1681,7 @@ function layoutSpaced(
1409
1681
  new Set(spacers.map((spacer) => spacer.at)),
1410
1682
  ).layout;
1411
1683
  const place = wraps(style) ? null : unwrappedPlacer(style, width);
1684
+ const lift = layoutLift(layout);
1412
1685
  let offsets: number[] | null = null;
1413
1686
  const lines: LineBox[] = [];
1414
1687
  let widest = 0;
@@ -1439,7 +1712,7 @@ function layoutSpaced(
1439
1712
  layout,
1440
1713
  layoutLine: i,
1441
1714
  drawX: dx,
1442
- drawY: 0,
1715
+ drawY: -lift,
1443
1716
  textStart: spans.documentAt(natural.start),
1444
1717
  textEnd: spans.documentAt(natural.end),
1445
1718
  layoutStart: natural.start,
@@ -1451,7 +1724,7 @@ function layoutSpaced(
1451
1724
  y: natural.y,
1452
1725
  width: natural.width,
1453
1726
  height: natural.height,
1454
- baseline: natural.baseline - natural.y,
1727
+ baseline: natural.baseline - natural.y - lift,
1455
1728
  texts: [text],
1456
1729
  textStart: text.textStart,
1457
1730
  textEnd: text.textEnd,
@@ -1460,6 +1733,7 @@ function layoutSpaced(
1460
1733
  });
1461
1734
  widest = Math.max(widest, natural.width);
1462
1735
  }
1736
+ if (keepsSpaces) hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
1463
1737
  return { lines, height: layout.height, width: widest };
1464
1738
  }
1465
1739
 
@@ -1513,6 +1787,7 @@ function emitLayout(
1513
1787
  place: LinePlacer | null = null,
1514
1788
  ): number {
1515
1789
  let widest = 0;
1790
+ const lift = layoutLift(layout);
1516
1791
  for (let i = 0; i < layout.lines.length; i += 1) {
1517
1792
  const natural = layout.lines[i];
1518
1793
  const dx = place ? place(natural) : 0;
@@ -1520,7 +1795,7 @@ function emitLayout(
1520
1795
  layout,
1521
1796
  layoutLine: i,
1522
1797
  drawX: xOff + dx,
1523
- drawY: yOff,
1798
+ drawY: yOff - lift,
1524
1799
  textStart: spans.documentAt(natural.start),
1525
1800
  textEnd: spans.documentAt(natural.end),
1526
1801
  layoutStart: natural.start,
@@ -1532,7 +1807,7 @@ function emitLayout(
1532
1807
  y: yOff + natural.y,
1533
1808
  width: natural.width,
1534
1809
  height: natural.height,
1535
- baseline: natural.baseline - natural.y,
1810
+ baseline: natural.baseline - natural.y - lift,
1536
1811
  texts: [text],
1537
1812
  textStart: text.textStart,
1538
1813
  textEnd: text.textEnd,
@@ -1588,6 +1863,7 @@ function layoutChunked(
1588
1863
  align: string,
1589
1864
  fonts: FontsLike,
1590
1865
  chunking: Chunking = CHUNKS,
1866
+ keepsSpaces = false,
1591
1867
  ): InlineResult {
1592
1868
  const wrapWords = overflowWrapOf(style, items);
1593
1869
  const lines: LineBox[] = [];
@@ -1601,7 +1877,7 @@ function layoutChunked(
1601
1877
 
1602
1878
  const { cut } = chunking;
1603
1879
  const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
1604
- const flush = (): void => {
1880
+ const flush = (last = false): void => {
1605
1881
  if (!chunkRuns.length) return;
1606
1882
  const chunkOptions = {
1607
1883
  maxWidth: wraps(style) || cut ? width : undefined,
@@ -1609,10 +1885,26 @@ function layoutChunked(
1609
1885
  align,
1610
1886
  direction: style.direction,
1611
1887
  overflowWrap: cutWrap(style, cut, wrapWords),
1888
+ fit: PARAGRAPH_FIT,
1612
1889
  ...cut,
1613
1890
  };
1614
1891
  let layout = fonts.layout(chunkRuns, base, chunkOptions);
1615
1892
  LAYOUT_RUNS.set(layout, chunkRuns);
1893
+ const held = keepsSpaces
1894
+ ? holdAtBreaks(
1895
+ layout,
1896
+ chunkRuns,
1897
+ chunkSpans,
1898
+ fonts,
1899
+ chunkOptions.maxWidth ?? width,
1900
+ last && !cut,
1901
+ )
1902
+ : null;
1903
+ if (held) {
1904
+ chunkRuns = held;
1905
+ layout = fonts.layout(chunkRuns, base, chunkOptions);
1906
+ LAYOUT_RUNS.set(layout, chunkRuns);
1907
+ }
1616
1908
  // a chunk ends at a forced break or at the paragraph's end, which is
1617
1909
  // where `justifiedRuns` takes a chunk's last line to end
1618
1910
  layout = justifiedLayout(
@@ -1624,10 +1916,14 @@ function layoutChunked(
1624
1916
  width,
1625
1917
  justification(style),
1626
1918
  ).layout;
1919
+ const from = lines.length;
1627
1920
  widest = Math.max(
1628
1921
  widest,
1629
1922
  emitLayout(layout, chunkSpans, 0, y, lines, place),
1630
1923
  );
1924
+ if (keepsSpaces) {
1925
+ hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
1926
+ }
1631
1927
  y += layout.height;
1632
1928
  chunkRuns = [];
1633
1929
  chunkSpans = new SpanMap();
@@ -1692,7 +1988,7 @@ function layoutChunked(
1692
1988
  text = text.slice(cut);
1693
1989
  }
1694
1990
  }
1695
- flush();
1991
+ flush(true);
1696
1992
  return { lines, height: y, width: widest };
1697
1993
  }
1698
1994
 
@@ -1717,8 +2013,26 @@ interface OpenLine {
1717
2013
  }
1718
2014
 
1719
2015
  type Placed =
1720
- | { kind: 'text'; at: number; item: LineText; reads: Reading }
1721
- | { kind: 'atomic'; at: number; item: AtomicPlacement }
2016
+ | {
2017
+ kind: 'text';
2018
+ at: number;
2019
+ item: LineText;
2020
+ reads: Reading;
2021
+ /** Where the text was laid out from, in a paragraph with bidi levels
2022
+ * (`ParagraphBidi`): its segment's place in the paragraph's text,
2023
+ * runs and spans, which `levelPieces` splits it by. */
2024
+ from?: { para: number; runs: TextRun[]; spans: SpanMap };
2025
+ /** Its UAX #9 level, once `levelPieces` has found it one. */
2026
+ level?: number;
2027
+ }
2028
+ | {
2029
+ kind: 'atomic';
2030
+ at: number;
2031
+ item: AtomicPlacement;
2032
+ /** Its object replacement character's place in the paragraph. */
2033
+ para?: number;
2034
+ level?: number;
2035
+ }
1722
2036
  | { kind: 'edge'; at: number; item: EdgePlacement };
1723
2037
 
1724
2038
  /** Which way a fragment's letters read: all one way, both, or neither —
@@ -1747,6 +2061,271 @@ function breaksAtEnd(open: OpenLine, style: ComputedStyle): boolean {
1747
2061
  );
1748
2062
  }
1749
2063
 
2064
+ /**
2065
+ * Whether a line may break before an inline box's opening edge, between
2066
+ * what the line has got to and `next`, the text after the edge: after an
2067
+ * atomic, or after the white space the line ends on, as `breaksAtEnd`
2068
+ * says, or where the text engine breaks the line's last text and `next`
2069
+ * run together (`breaksBetween`). An edge on the way is no place to break
2070
+ * of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
2071
+ * yet has none to hold on to.
2072
+ */
2073
+ function breaksAfterLine(
2074
+ open: OpenLine,
2075
+ style: ComputedStyle,
2076
+ fonts: FontsLike,
2077
+ base: Record<string, unknown>,
2078
+ next: readonly TextRun[],
2079
+ anywhere: boolean,
2080
+ ): boolean {
2081
+ if (!wraps(style)) return false;
2082
+ if (open.hang > 0) return true;
2083
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
2084
+ const placed = open.order[i];
2085
+ if (placed.kind === 'edge') continue;
2086
+ if (placed.kind === 'atomic') return true;
2087
+ const text = placed.item;
2088
+ const runs = LAYOUT_RUNS.get(text.layout);
2089
+ if (!runs || (runs.length > 0 && typeof runs[0] === 'number')) return true;
2090
+ const end = text.layout.lines[text.layoutLine].end;
2091
+ return breaksBetween(fonts, base, runs as TextRun[], end, next, anywhere);
2092
+ }
2093
+ return true;
2094
+ }
2095
+
2096
+ /**
2097
+ * How wide the edges are between the text the open line ends on and text
2098
+ * starting at `start` in `runs`, where the one runs on into the other
2099
+ * (`JOINS`): neither is white space at the join, and no atomic, which has a
2100
+ * break before and after it, is between them. Undefined where they do not
2101
+ * join, or the line has no text yet. Letters on either side are taken to
2102
+ * join whatever they are, which may count an ideograph's join that the
2103
+ * engine would break at: it makes the bound it is for a larger one, never a
2104
+ * smaller.
2105
+ */
2106
+ function joinsOnto(
2107
+ open: OpenLine,
2108
+ runs: readonly TextRun[],
2109
+ start: number,
2110
+ ): number | undefined {
2111
+ if (open.hang > 0) return undefined;
2112
+ const next = charAt(runs, start);
2113
+ if (next === '' || SPACE.test(next)) return undefined;
2114
+ let edges = 0;
2115
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
2116
+ const placed = open.order[i];
2117
+ if (placed.kind === 'edge') {
2118
+ edges += placed.item.width;
2119
+ continue;
2120
+ }
2121
+ if (placed.kind === 'atomic') return undefined;
2122
+ const text = placed.item;
2123
+ const before = LAYOUT_RUNS.get(text.layout);
2124
+ if (!before || (before.length > 0 && typeof before[0] === 'number')) {
2125
+ return undefined;
2126
+ }
2127
+ const last = charAt(
2128
+ before as TextRun[],
2129
+ text.layout.lines[text.layoutLine].end - 1,
2130
+ );
2131
+ return last === '' || SPACE.test(last) ? undefined : edges;
2132
+ }
2133
+ return undefined;
2134
+ }
2135
+
2136
+ /**
2137
+ * Whether a line may break between two texts an inline box's edge stands
2138
+ * between — the text of `before` up to the code unit `end`, and `after` —
2139
+ * which is where it may break them run together, the edge being no place
2140
+ * to break (CSS Text 3, 5.1). After white space, and not before it, where
2141
+ * the break is after that white space instead (UAX #14, LB7). Never between
2142
+ * two letters or digits of the alphabets whose words break only at spaces,
2143
+ * which is every edge in running text, and needs no asking. Anything else
2144
+ * — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
2145
+ * of the text engine: the two words, laid out at no width, break at every
2146
+ * place a line may, which is UAX #14 as the `linebreak` package has it on
2147
+ * both backends — ntk breaks every line with it, and react-x11's CoreText
2148
+ * engine a text at no width. So an edge after a hyphen, or between two
2149
+ * ideographs, is a place to break exactly where the same text with no edge
2150
+ * in it is one; a list of classes here would be a third opinion.
2151
+ *
2152
+ * Counted, not located: the words run together break into as many pieces
2153
+ * as the two apart where they break between them, and one fewer where the
2154
+ * last piece of the one runs on into the first of the other. The line
2155
+ * offsets are no answer, as they are not the text's on every backend: the
2156
+ * CoreText engine lays out a copy with a line break put in at each place,
2157
+ * and its offsets are that copy's.
2158
+ */
2159
+ function breaksBetween(
2160
+ fonts: FontsLike,
2161
+ base: Record<string, unknown>,
2162
+ before: readonly TextRun[],
2163
+ end: number,
2164
+ after: readonly TextRun[],
2165
+ anywhere: boolean,
2166
+ ): boolean {
2167
+ const last = charAt(before, end - 1);
2168
+ const next = charAt(after, 0);
2169
+ // nothing to hold the two together
2170
+ if (last === '' || next === '') return true;
2171
+ if (SPACE.test(last)) return true;
2172
+ if (SPACE.test(next)) return false;
2173
+ if (anywhere) return true;
2174
+ if (HOLDS.test(last) && HOLDS.test(next)) return false;
2175
+ if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next)) return false;
2176
+ const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
2177
+ const head = sliceRuns(after, 0, firstSpace(after));
2178
+ const pieces = (runs: TextRun[]): number =>
2179
+ fonts.layout(runs, base, {
2180
+ maxWidth: MIN_CONTENT_WIDTH,
2181
+ overflowWrap: 'normal',
2182
+ }).lines.length;
2183
+ return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
2184
+ }
2185
+
2186
+ /** The width a text is laid out at to break it at every place it may
2187
+ * break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
2188
+ * `MIN_CONTENT_PROBE`). */
2189
+ const MIN_CONTENT_WIDTH = 0;
2190
+
2191
+ /** White space a line breaks after. */
2192
+ const SPACE = /^[ \t\n]$/;
2193
+
2194
+ /** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
2195
+ * marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
2196
+ * LB25, LB28). */
2197
+ const HOLDS =
2198
+ /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
2199
+
2200
+ /** What no line breaks after, whatever follows: an opening bracket, a
2201
+ * straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
2202
+ const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
2203
+
2204
+ /** What no line breaks before, whatever precedes: a closing bracket, a
2205
+ * stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
2206
+ * LB13, LB19) — the punctuation after a link or a `<code>`, which needs
2207
+ * no asking either. */
2208
+ const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
2209
+
2210
+ /** The code unit at an offset into runs' joined text, or '' outside it. */
2211
+ function charAt(runs: readonly TextRun[], at: number): string {
2212
+ if (at < 0) return '';
2213
+ let from = 0;
2214
+ for (const run of runs) {
2215
+ const next = from + run.text.length;
2216
+ if (at < next) return run.text[at - from];
2217
+ from = next;
2218
+ }
2219
+ return '';
2220
+ }
2221
+
2222
+ /** Where the last white space before a code-unit offset is, or -1. */
2223
+ function lastSpace(runs: readonly TextRun[], end: number): number {
2224
+ let found = -1;
2225
+ let from = 0;
2226
+ for (const run of runs) {
2227
+ if (from >= end) break;
2228
+ const text = run.text.slice(0, end - from);
2229
+ const at = Math.max(
2230
+ text.lastIndexOf(' '),
2231
+ text.lastIndexOf('\t'),
2232
+ text.lastIndexOf('\n'),
2233
+ );
2234
+ if (at >= 0) found = from + at;
2235
+ from += run.text.length;
2236
+ }
2237
+ return found;
2238
+ }
2239
+
2240
+ /** Where the first white space in runs' joined text is, or its length. */
2241
+ function firstSpace(runs: readonly TextRun[]): number {
2242
+ let from = 0;
2243
+ for (const run of runs) {
2244
+ const at = run.text.search(/[ \t\n]/);
2245
+ if (at >= 0) return from + at;
2246
+ from += run.text.length;
2247
+ }
2248
+ return from;
2249
+ }
2250
+
2251
+ function runsLength(runs: readonly TextRun[]): number {
2252
+ let length = 0;
2253
+ for (const run of runs) length += run.text.length;
2254
+ return length;
2255
+ }
2256
+
2257
+ /**
2258
+ * The text inline boxes' edges from `from` on stand before, up to its first
2259
+ * white space: what the text before the edges runs on into. Null where an
2260
+ * atomic comes first, which has a break before it, or nothing does.
2261
+ */
2262
+ function textAfterEdges(
2263
+ items: readonly Item[],
2264
+ from: number,
2265
+ ): TextRun[] | null {
2266
+ const runs: TextRun[] = [];
2267
+ for (let i = from; i < items.length; i += 1) {
2268
+ const item = items[i];
2269
+ if (item.kind === 'edge' || item.kind === 'float') continue;
2270
+ if (item.kind === 'atomic') break;
2271
+ const text = item.run.text;
2272
+ const stop = text.search(/[ \t\n]/);
2273
+ if (stop < 0) {
2274
+ runs.push(item.run);
2275
+ continue;
2276
+ }
2277
+ runs.push({ ...item.run, text: text.slice(0, stop + 1) });
2278
+ break;
2279
+ }
2280
+ return runs.length ? runs : null;
2281
+ }
2282
+
2283
+ /**
2284
+ * The most `unbreakableAfter` could come to, with no layout: the edges on
2285
+ * the way, and the text up to its first white space at half again its size
2286
+ * a character, which no glyph a document sets words in is wider than, and
2287
+ * its letter spacing (`longestWord`'s estimate).
2288
+ */
2289
+ function unbreakableBound(items: readonly Item[], from: number): number {
2290
+ let width = 0;
2291
+ for (let i = from; i < items.length; i += 1) {
2292
+ const item = items[i];
2293
+ if (item.kind === 'float') continue;
2294
+ if (item.kind === 'edge') {
2295
+ width += Math.max(0, item.width);
2296
+ continue;
2297
+ }
2298
+ if (item.kind === 'atomic') break;
2299
+ const run = item.run;
2300
+ const stop = run.text.search(/[ \t\n]/);
2301
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
2302
+ width += (stop < 0 ? run.text.length : stop) * per;
2303
+ if (stop >= 0) break;
2304
+ }
2305
+ return width;
2306
+ }
2307
+
2308
+ /** How wide the edges from `from` on are that close inline boxes, up to
2309
+ * one that opens a box or anything but an edge or a float. */
2310
+ function closingEdges(items: readonly Item[], from: number): number {
2311
+ let width = 0;
2312
+ for (let i = from; i < items.length; i += 1) {
2313
+ const item = items[i];
2314
+ if (item.kind === 'float') continue;
2315
+ if (item.kind !== 'edge' || item.side !== 'end') break;
2316
+ width += item.width;
2317
+ }
2318
+ return width;
2319
+ }
2320
+
2321
+ /** Whether a code-unit offset into runs' joined text is between two
2322
+ * letters or digits: inside a word, where only `overflow-wrap` breaks. */
2323
+ function insideWord(runs: readonly TextRun[], at: number): boolean {
2324
+ return (
2325
+ WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at))
2326
+ );
2327
+ }
2328
+
1750
2329
  /**
1751
2330
  * How wide the content from `from` on is up to where its line may break:
1752
2331
  * its text up to a space it may break at — one `holdNoWrap` left a space —
@@ -1785,7 +2364,9 @@ function unbreakableAfter(
1785
2364
  if (stop > 0) runs.push({ ...item.run, text: text.slice(0, stop) });
1786
2365
  break;
1787
2366
  }
1788
- return runs.length ? width + fonts.layout(runs, base, {}).width : width;
2367
+ return runs.length
2368
+ ? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
2369
+ : width;
1789
2370
  }
1790
2371
 
1791
2372
  /**
@@ -1803,11 +2384,16 @@ function finishLine(
1803
2384
  strut: InlineDecoration | null,
1804
2385
  lifts: Lifts | null = null,
1805
2386
  justify: LineJustify | null = null,
2387
+ /** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
2388
+ levels: LineLevels | null = null,
1806
2389
  ): LineBox | null {
1807
2390
  if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1808
2391
  return null;
1809
2392
  }
1810
- const reordered = open.order.some((p) => needsOrdering(p, rtl));
2393
+ const reordered = levels
2394
+ ? levelPieces(open, levels) ||
2395
+ open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
2396
+ : open.order.some((p) => needsOrdering(p, rtl));
1811
2397
  if (reordered) reorderLine(open, rtl);
1812
2398
  // Every line box starts with the block's strut, its face at its line
1813
2399
  // height, so a line of images alone is still as tall as `line-height`
@@ -1856,7 +2442,9 @@ function finishLine(
1856
2442
  continue;
1857
2443
  }
1858
2444
  const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1859
- const own = natural.baseline - natural.y;
2445
+ // with its leading shared as the strut's is: it is drawn on the line's
2446
+ // baseline, wherever its engine put its own
2447
+ const own = lineAscent(natural);
1860
2448
  ascent = Math.max(ascent, own);
1861
2449
  descent = Math.max(descent, natural.height - own);
1862
2450
  }
@@ -2013,10 +2601,8 @@ function finishLine(
2013
2601
  * UAX #9's L2 over pieces rather than characters: a fragment is in order
2014
2602
  * inside itself already — the engine laid it out bidirectionally — so what
2015
2603
  * is left to order is the fragments and the atomics between them, each at
2016
- * a level — a fragment the direction its letters read in, or the
2017
- * paragraph's where they read both ways, and a neutral (an atomic, a
2018
- * fragment of spaces and digits) its neighbours' where they agree and the
2019
- * paragraph's where they do not (N1, N2).
2604
+ * a level: the paragraph's own, where it has them (`levelPieces`), and
2605
+ * otherwise the one it reads at (`readingLevels`).
2020
2606
  *
2021
2607
  * Then each element's edges, innermost first, around what of it is on the
2022
2608
  * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
@@ -2032,25 +2618,16 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
2032
2618
  if (!n) return;
2033
2619
  const content: number[] = [];
2034
2620
  for (let i = 0; i < n; i += 1) if (order[i].kind !== 'edge') content.push(i);
2035
- const strong = content.map((i): boolean | null => {
2621
+ // UAX #9's levels where the paragraph's are known (`levelPieces`), each
2622
+ // piece at one of them; its reading otherwise
2623
+ const given = content.map((i) => {
2036
2624
  const p = order[i];
2037
- if (p.kind !== 'text' || p.reads === null) return null;
2038
- return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2039
- });
2040
- const strongAt = (from: number, step: number): boolean => {
2041
- for (let k = from + step; k >= 0 && k < content.length; k += step) {
2042
- if (strong[k] !== null) return strong[k]!;
2043
- }
2044
- return rtl;
2045
- };
2046
- const isRtl = strong.map((r, k) => {
2047
- if (r !== null) return r;
2048
- const before = strongAt(k, -1);
2049
- return before === strongAt(k, 1) ? before : rtl;
2625
+ return p.kind === 'edge' ? undefined : p.level;
2050
2626
  });
2051
- // R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
2052
- // in a right-to-left one
2053
- const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
2627
+ const levels = given.every((level) => level !== undefined)
2628
+ ? (given as number[])
2629
+ : readingLevels(order, content, rtl);
2630
+ const isRtl = levels.map((level) => (level & 1) === 1);
2054
2631
  const visual = content.map((_, k) => k);
2055
2632
  for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
2056
2633
  for (let a = 0; a < visual.length;) {
@@ -2121,6 +2698,8 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
2121
2698
  const k = content.indexOf(i);
2122
2699
  if (isRtl[k] && natural) dx += Math.max(0, room - natural.width);
2123
2700
  piece.item.drawX += dx;
2701
+ // and the spaces it hung are on the side of it its room is left on
2702
+ if (piece.item.hung) placeHung(piece.item, isRtl[k]);
2124
2703
  } else {
2125
2704
  piece.item.x += dx;
2126
2705
  }
@@ -2128,6 +2707,473 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
2128
2707
  }
2129
2708
  }
2130
2709
 
2710
+ /**
2711
+ * The levels of a line's pieces from which way each reads, where the
2712
+ * paragraph's are not known: a fragment the direction its letters read in,
2713
+ * or the paragraph's where they read both ways, and a neutral — an atomic, a
2714
+ * fragment of spaces and digits — its neighbours' where they agree and the
2715
+ * paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
2716
+ * L is 0 in a left-to-right one and 2 in a right-to-left one.
2717
+ */
2718
+ function readingLevels(
2719
+ order: Placed[],
2720
+ content: number[],
2721
+ rtl: boolean,
2722
+ ): number[] {
2723
+ const strong = content.map((i): boolean | null => {
2724
+ const p = order[i];
2725
+ if (p.kind !== 'text' || p.reads === null) return null;
2726
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2727
+ });
2728
+ const strongAt = (from: number, step: number): boolean => {
2729
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2730
+ if (strong[k] !== null) return strong[k]!;
2731
+ }
2732
+ return rtl;
2733
+ };
2734
+ return strong.map((r, k) => {
2735
+ const reads =
2736
+ r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
2737
+ return reads ? 1 : rtl ? 2 : 0;
2738
+ });
2739
+ }
2740
+
2741
+ /**
2742
+ * A paragraph's UAX #9 levels, for its lines made a piece at a time. The
2743
+ * engine resolves bidi over what it is handed, which on such a line is a
2744
+ * piece: an embedding or an override opened on one side of an inline box's
2745
+ * edge or an atomic and closed on the other was resolved on each side
2746
+ * apart, and a neutral at a piece's edge took the paragraph's direction
2747
+ * wherever it sat (#149). So they are resolved here, once, over the
2748
+ * paragraph's text as its layouts see it — the controls `unicode-bidi`
2749
+ * stands for among it, and an object replacement character for each
2750
+ * atomic, as CSS Writing Modes 3 has one taken (2.4.2) — by bidi-js, which
2751
+ * is what ntk's own layout resolves them with.
2752
+ */
2753
+ interface ParagraphBidi {
2754
+ text: string;
2755
+ levels: Uint8Array;
2756
+ /** Whether it holds an explicit embedding, override or isolate, which
2757
+ * a piece laid out alone may be outside of. */
2758
+ explicit: boolean;
2759
+ /** The paragraph's own level: 1 where it reads right to left. */
2760
+ base: number;
2761
+ /** Where each item's text starts in `text`, by the item's index. */
2762
+ starts: number[];
2763
+ }
2764
+
2765
+ /** What a line's pieces are ordered by (`levelPieces`): the paragraph's
2766
+ * levels, and the fonts, font and line height a piece is laid out again
2767
+ * in where it is split. */
2768
+ interface LineLevels {
2769
+ bidi: ParagraphBidi;
2770
+ fonts: FontsLike;
2771
+ base: Record<string, unknown>;
2772
+ lineHeight: number;
2773
+ }
2774
+
2775
+ type TextPlaced = Extract<Placed, { kind: 'text' }>;
2776
+
2777
+ let BIDI: Bidi | null = null;
2778
+
2779
+ /** bidi-js, made the first time a paragraph needs it. Its declarations
2780
+ * give a CommonJS module a default export, which NodeNext reads as the
2781
+ * module object; Node and a bundler both hand over its factory. */
2782
+ function bidiJs(): Bidi {
2783
+ if (!BIDI) {
2784
+ const factory = bidiModule as unknown as
2785
+ (() => Bidi) | { default: () => Bidi };
2786
+ BIDI = (typeof factory === 'function' ? factory : factory.default)();
2787
+ }
2788
+ return BIDI;
2789
+ }
2790
+
2791
+ /** A paragraph's levels, or null where nothing in it can be reordered:
2792
+ * it reads left to right, and holds no right-to-left letter and no bidi
2793
+ * control. Kept on its block, and resolved again only where its text is
2794
+ * not what it was: a pass at another width has the same. */
2795
+ function paragraphBidi(
2796
+ block: Box,
2797
+ items: Item[],
2798
+ rtl: boolean,
2799
+ ): ParagraphBidi | null {
2800
+ let reorders = rtl;
2801
+ for (let i = 0; !reorders && i < items.length; i += 1) {
2802
+ const item = items[i];
2803
+ if (item.kind === 'text' && REORDERS.test(item.run.text)) reorders = true;
2804
+ }
2805
+ if (!reorders) return null;
2806
+ const starts: number[] = [];
2807
+ let text = '';
2808
+ for (const item of items) {
2809
+ starts.push(text.length);
2810
+ if (item.kind === 'text') text += item.run.text;
2811
+ else if (item.kind === 'atomic') text += '\ufffc';
2812
+ }
2813
+ const base = rtl ? 1 : 0;
2814
+ const kept = PARAGRAPH_BIDI.get(block);
2815
+ if (kept && kept.text === text && kept.base === base) {
2816
+ return { ...kept, starts };
2817
+ }
2818
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2819
+ const explicit = EXPLICIT.test(text);
2820
+ const bidi = { text, levels, explicit, base, starts };
2821
+ PARAGRAPH_BIDI.set(block, bidi);
2822
+ return bidi;
2823
+ }
2824
+
2825
+ const PARAGRAPH_BIDI = new WeakMap<Box, ParagraphBidi>();
2826
+
2827
+ /** The explicit embeddings, overrides and isolates. */
2828
+ const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
2829
+
2830
+ /** The explicit embeddings, overrides and isolates a text ends on. */
2831
+ const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
2832
+
2833
+ /** Whether a character is one X9 removes, or an isolate: a bidi control,
2834
+ * or a boundary neutral such as a zero-width space or a soft hyphen. */
2835
+ function isControl(c: number): boolean {
2836
+ return (
2837
+ (c >= 0x202a && c <= 0x202e) ||
2838
+ (c >= 0x2066 && c <= 0x2069) ||
2839
+ (c >= 0x200b && c <= 0x200d) ||
2840
+ (c >= 0x2060 && c <= 0x2064) ||
2841
+ c === 0xad ||
2842
+ c === 0xfeff ||
2843
+ c === 0x180e
2844
+ );
2845
+ }
2846
+
2847
+ /** The paired brackets UAX #9 resolves together (N0), which may pair
2848
+ * across a piece's end. */
2849
+ const BRACKETS =
2850
+ /[()[\]{}\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]/;
2851
+
2852
+ /** The strong types, which nothing around them resolves again. */
2853
+ const STRONG = new Set(['L', 'R', 'AL']);
2854
+
2855
+ /** What UAX #9 L1 puts back at the paragraph's level where a line ends on
2856
+ * it: white space, the isolates, and what X9 removed. */
2857
+ const TRAILING = new Set([
2858
+ 'WS',
2859
+ 'S',
2860
+ 'B',
2861
+ 'LRI',
2862
+ 'RLI',
2863
+ 'FSI',
2864
+ 'PDI',
2865
+ 'BN',
2866
+ 'LRE',
2867
+ 'RLE',
2868
+ 'LRO',
2869
+ 'RLO',
2870
+ 'PDF',
2871
+ ]);
2872
+
2873
+ /** A segment's text, and its levels as the engine resolves them, laid out
2874
+ * alone in the paragraph's direction. */
2875
+ const ALONE = new WeakMap<TextRun[], { text: string; levels: Uint8Array }>();
2876
+
2877
+ function aloneLevels(
2878
+ runs: TextRun[],
2879
+ rtl: boolean,
2880
+ ): { text: string; levels: Uint8Array } {
2881
+ let alone = ALONE.get(runs);
2882
+ if (!alone) {
2883
+ const text = runs.map((run) => run.text).join('');
2884
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2885
+ alone = { text, levels };
2886
+ ALONE.set(runs, alone);
2887
+ }
2888
+ return alone;
2889
+ }
2890
+
2891
+ /** Whether the text from a segment on, laid out alone, is ordered as the
2892
+ * paragraph orders it: every letter at the level it has there. */
2893
+ function orderedAlone(
2894
+ bidi: ParagraphBidi,
2895
+ segment: Segment,
2896
+ para: number,
2897
+ rtl: boolean,
2898
+ ): boolean {
2899
+ const { text, levels } = aloneLevels(segment.runs, rtl);
2900
+ for (let at = 0; at < levels.length; at += 1) {
2901
+ if (segment.spans.isGap(at)) continue;
2902
+ if (levels[at] === bidi.levels[para + at]) continue;
2903
+ if (!isControl(text.charCodeAt(at))) return false;
2904
+ }
2905
+ return true;
2906
+ }
2907
+
2908
+ /**
2909
+ * Give each piece on a line its level, and answer whether the line is to be
2910
+ * put in visual order at all. A piece's letters have the paragraph's levels,
2911
+ * with the white space the line ends on back at the paragraph's (L1). A
2912
+ * fragment was laid out alone and ordered inside itself by the engine: it
2913
+ * is kept whole where the engine's levels for it are the paragraph's and it
2914
+ * begins and ends at its lowest one, so that nothing of another piece comes
2915
+ * between its letters (L2), and it is the line's at that level. It is split
2916
+ * otherwise, a layout to each run of its letters at one level, held in that
2917
+ * level's direction by an override; the controls in it, which take no
2918
+ * room, are left out. An atomic is at its replacement character's level.
2919
+ */
2920
+ function levelPieces(open: OpenLine, line: LineLevels): boolean {
2921
+ const { bidi } = line;
2922
+ let start = Infinity;
2923
+ let end = -Infinity;
2924
+ for (const p of open.order) {
2925
+ if (p.kind === 'text' && p.from) {
2926
+ const natural = p.item.layout.lines[p.item.layoutLine];
2927
+ if (!natural) continue;
2928
+ start = Math.min(start, p.from.para + natural.start);
2929
+ end = Math.max(end, p.from.para + natural.end);
2930
+ } else if (p.kind === 'atomic' && p.para !== undefined) {
2931
+ start = Math.min(start, p.para);
2932
+ end = Math.max(end, p.para + 1);
2933
+ }
2934
+ }
2935
+ // an element's edges alone are ordered as the paragraph reads
2936
+ if (!(end > start)) return bidi.base > 0;
2937
+ const levels = bidi.levels.slice(start, end);
2938
+ const js = bidiJs();
2939
+ for (let i = end - 1; i >= start; i -= 1) {
2940
+ if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i]))) break;
2941
+ levels[i - start] = bidi.base;
2942
+ }
2943
+ const levelAt = (at: number): number => levels[at - start];
2944
+ let off = false;
2945
+ const order: Placed[] = [];
2946
+ const texts: LineText[] = [];
2947
+ for (const p of open.order) {
2948
+ if (p.kind === 'edge') {
2949
+ order.push(p);
2950
+ continue;
2951
+ }
2952
+ if (p.kind === 'atomic') {
2953
+ p.level = p.para === undefined ? bidi.base : levelAt(p.para);
2954
+ off ||= p.level > 0;
2955
+ order.push(p);
2956
+ continue;
2957
+ }
2958
+ for (const piece of levelled(p, levelAt, line)) {
2959
+ off ||= piece.level! > 0;
2960
+ order.push(piece);
2961
+ texts.push(piece.item);
2962
+ }
2963
+ }
2964
+ open.order = order;
2965
+ open.texts = texts;
2966
+ return off;
2967
+ }
2968
+
2969
+ /** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
2970
+ function levelled(
2971
+ p: TextPlaced,
2972
+ levelAt: (at: number) => number,
2973
+ line: LineLevels,
2974
+ ): TextPlaced[] {
2975
+ const text = p.item;
2976
+ const natural = text.layout.lines[text.layoutLine];
2977
+ const base = line.bidi.base;
2978
+ if (!p.from || !natural) return [{ ...p, level: base }];
2979
+ const { para, runs, spans } = p.from;
2980
+ const paragraph = line.bidi.text;
2981
+ // A bidi control takes no room and has no level of its own, whether
2982
+ // `unicode-bidi` stands for it or the document holds it: X9 removes it,
2983
+ // and bidi-js gives it the level of what is before it, which may be
2984
+ // another piece's. It is no end of a piece, nor of a run of one level.
2985
+ const quiet = (at: number): boolean =>
2986
+ spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
2987
+ let lowest = Infinity;
2988
+ let first = -1;
2989
+ let last = -1;
2990
+ for (let at = natural.start; at < natural.end; at += 1) {
2991
+ if (quiet(at)) continue;
2992
+ if (first < 0) first = at;
2993
+ last = at;
2994
+ lowest = Math.min(lowest, levelAt(para + at));
2995
+ }
2996
+ if (first < 0) return [{ ...p, level: base }];
2997
+ if (
2998
+ levelAt(para + first) === lowest &&
2999
+ levelAt(para + last) === lowest &&
3000
+ (plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
3001
+ laidAsLevelled(p.from, natural, levelAt, base))
3002
+ ) {
3003
+ return [{ ...p, level: lowest }];
3004
+ }
3005
+ const starts = runStarts(runs);
3006
+ const out: TextPlaced[] = [];
3007
+ let at = p.at;
3008
+ let lastRuns: TextRun[] = [];
3009
+ /** Where each piece is in the fragment's segment. */
3010
+ const ranges: [number, number][] = [];
3011
+ const piece = (from: number, to: number, level: number): void => {
3012
+ // its letters, each run's with where it is in the document and whose
3013
+ // text it is
3014
+ const letters: { run: TextRun; doc: number; box: Box | null }[] = [];
3015
+ let points = 0;
3016
+ for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
3017
+ const runStart = starts[k];
3018
+ if (runStart >= to) break;
3019
+ const a = Math.max(from, runStart);
3020
+ const b = Math.min(to, runStart + runs[k].text.length);
3021
+ if (b <= a || spans.isGap(a)) continue;
3022
+ const slice = runs[k].text.slice(a - runStart, b - runStart);
3023
+ letters.push({
3024
+ run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
3025
+ doc: spans.documentAt(a),
3026
+ box: spans.boxAt(a),
3027
+ });
3028
+ points += codeUnitOffsets(slice).length - 1;
3029
+ }
3030
+ if (!letters.length) return;
3031
+ const head = letters[0];
3032
+ const tail = letters[letters.length - 1];
3033
+ const docStart = head.doc;
3034
+ const docEnd = tail.doc + tail.run.text.length;
3035
+ const face = (run: TextRun, control: string): TextRun => ({
3036
+ text: control,
3037
+ family: run.family,
3038
+ size: run.size,
3039
+ weight: run.weight,
3040
+ style: run.style,
3041
+ });
3042
+ const rtl = (level & 1) === 1;
3043
+ const laidRuns: TextRun[] = [face(head.run, rtl ? '\u202e' : '\u202d')];
3044
+ const laidSpans = new SpanMap();
3045
+ laidSpans.add(docStart, 1, head.box, true);
3046
+ for (const { run, doc, box } of letters) {
3047
+ laidSpans.add(doc, run.text.length, box);
3048
+ laidRuns.push(run);
3049
+ }
3050
+ laidRuns.push(face(tail.run, '\u202c'));
3051
+ laidSpans.add(docEnd, 1, tail.box, true);
3052
+ const layout = line.fonts.layout(laidRuns, line.base, {
3053
+ lineHeight: line.lineHeight,
3054
+ align: 'left',
3055
+ direction: rtl ? 'rtl' : 'ltr',
3056
+ fit: PARAGRAPH_FIT,
3057
+ });
3058
+ LAYOUT_RUNS.set(layout, laidRuns);
3059
+ lastRuns = laidRuns;
3060
+ const laid = layout.lines[0];
3061
+ const item: LineText = {
3062
+ layout,
3063
+ layoutLine: 0,
3064
+ drawX: text.drawX + (at - p.at),
3065
+ drawY: 0,
3066
+ textStart: docStart,
3067
+ textEnd: docEnd,
3068
+ layoutStart: laid ? laid.start : 0,
3069
+ spans: laidSpans,
3070
+ };
3071
+ laidSpans.giveGaps(item, 0, laidSpans.laidOut);
3072
+ out.push({ kind: 'text', at, item, reads: null, level });
3073
+ ranges.push([from, to]);
3074
+ // the room it takes, its white space at its end with it, which the
3075
+ // engine hangs past the layout's width: from caret to caret
3076
+ const extent = Math.abs(
3077
+ layout.caretPosition(1 + points).x - layout.caretPosition(1).x,
3078
+ );
3079
+ at += Math.max(laid?.width ?? 0, extent);
3080
+ };
3081
+ let from = first;
3082
+ let level = levelAt(para + first);
3083
+ let prev = first;
3084
+ for (let o = first + 1; o <= last; o += 1) {
3085
+ if (quiet(o)) continue;
3086
+ const l = levelAt(para + o);
3087
+ if (l !== level) {
3088
+ piece(from, prev + 1, level);
3089
+ from = o;
3090
+ level = l;
3091
+ }
3092
+ prev = o;
3093
+ }
3094
+ piece(from, last + 1, level);
3095
+ if (!out.length) return [{ ...p, level: base }];
3096
+ // a word across two of the pieces runs on from one to the next, with no
3097
+ // edge between, as the fragment ran on from the text before it (`JOINS`)
3098
+ const joins = JOINS.get(text);
3099
+ if (joins !== undefined) JOINS.set(out[0].item, joins);
3100
+ for (let k = 1; k < out.length; k += 1) {
3101
+ const before = paragraph[para + ranges[k - 1][1] - 1];
3102
+ const after = paragraph[para + ranges[k][0]];
3103
+ if (!SPACE.test(before) && !SPACE.test(after)) JOINS.set(out[k].item, 0);
3104
+ }
3105
+ // the spaces `pre-wrap` keeps that the fragment ended on are its last
3106
+ // piece's, which the engine hangs as it hung them (`hangPreserved`)
3107
+ const tail = out[out.length - 1];
3108
+ if (text.hung) {
3109
+ const rtl = (tail.level! & 1) === 1;
3110
+ hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
3111
+ }
3112
+ return out;
3113
+ }
3114
+
3115
+ /**
3116
+ * Whether the engine, laying a fragment out alone, must have given its
3117
+ * letters the paragraph's levels, without resolving them to see: in a
3118
+ * paragraph of no explicit embedding, a stretch that begins and ends with a
3119
+ * strong letter and pairs no bracket is resolved inside itself, whatever is
3120
+ * around it (W1 to W7, N1 and N2 look no further than the strong letters
3121
+ * either side). The white space it ends on is the paragraph's level to the
3122
+ * engine, whose line ends there (L1), and has to be to the line.
3123
+ */
3124
+ function plainlyLevelled(
3125
+ bidi: ParagraphBidi,
3126
+ from: NonNullable<TextPlaced['from']>,
3127
+ first: number,
3128
+ last: number,
3129
+ levelAt: (at: number) => number,
3130
+ ): boolean {
3131
+ if (bidi.explicit) return false;
3132
+ const text = bidi.text;
3133
+ const js = bidiJs();
3134
+ let end = last;
3135
+ while (
3136
+ end > first &&
3137
+ TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))
3138
+ ) {
3139
+ if (levelAt(from.para + end) !== bidi.base) return false;
3140
+ end -= 1;
3141
+ }
3142
+ return (
3143
+ STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
3144
+ STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
3145
+ !BRACKETS.test(text.slice(from.para + first, from.para + end + 1))
3146
+ );
3147
+ }
3148
+
3149
+ /** Whether the engine, laying a fragment out alone, gave its letters the
3150
+ * levels the paragraph gives them: with its line's end white space back
3151
+ * at the paragraph's level (L1), as the engine's own line ends there. */
3152
+ function laidAsLevelled(
3153
+ from: NonNullable<TextPlaced['from']>,
3154
+ natural: TextLayoutLike['lines'][number],
3155
+ levelAt: (at: number) => number,
3156
+ base: number,
3157
+ ): boolean {
3158
+ const { text, levels } = aloneLevels(from.runs, base === 1);
3159
+ const js = bidiJs();
3160
+ const quiet = (at: number): boolean =>
3161
+ from.spans.isGap(at) || isControl(text.charCodeAt(at));
3162
+ let hung = natural.end;
3163
+ while (
3164
+ hung > natural.start &&
3165
+ TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))
3166
+ ) {
3167
+ hung -= 1;
3168
+ }
3169
+ for (let at = natural.start; at < natural.end; at += 1) {
3170
+ if (quiet(at)) continue;
3171
+ const alone = at >= hung ? base : levels[at];
3172
+ if (alone !== levelAt(from.para + at)) return false;
3173
+ }
3174
+ return true;
3175
+ }
3176
+
2131
3177
  /** Whether a piece can be out of place in logical order: anything on a
2132
3178
  * right-to-left line, a fragment that reads right to left, or the edge of
2133
3179
  * an element whose direction is not the line's. A line of left-to-right
@@ -2255,7 +3301,14 @@ function atomicBaseline(box: Box): number {
2255
3301
  const bottom = box.height + box.marginTop + box.marginBottom;
2256
3302
  if (
2257
3303
  box.kind === 'replaced' ||
2258
- scrolls(box.style) ||
3304
+ // a box that clips sits on its bottom margin edge, for legacy reasons
3305
+ // that stop at block containers (CSS Box Alignment 3, 9.2) — and an
3306
+ // inline table, as browsers keep it: an inline flex box that clips
3307
+ // sits on its first item's, as it does unclipped. A MediaWiki button
3308
+ // is an `overflow: hidden` inline flex box of one icon, and stood its
3309
+ // whole height on the baseline, the line under it the strut's descent
3310
+ // taller
3311
+ (scrolls(box.style) && box.kind !== 'flex') ||
2259
3312
  // layout containment keeps its baseline in (CSS Containment 2, 3.3)
2260
3313
  box.style.contain & CONTAIN_LAYOUT
2261
3314
  ) {
@@ -2390,7 +3443,13 @@ function childBaseline(
2390
3443
  if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
2391
3444
  return null;
2392
3445
  }
2393
- if (scrolls(child.style)) {
3446
+ // the legacy rule is a block container's last baseline alone (CSS Box
3447
+ // Alignment 3, 9.2): its first is its first line's, clipped or not
3448
+ if (
3449
+ inside === lastBaselineIn &&
3450
+ child.kind !== 'flex' &&
3451
+ scrolls(child.style)
3452
+ ) {
2394
3453
  return child.y + child.height + child.marginBottom;
2395
3454
  }
2396
3455
  return inside(child);
@@ -2815,7 +3874,12 @@ function wordSpaced(
2815
3874
  run: {
2816
3875
  ...run,
2817
3876
  text: text.slice(from, to),
2818
- ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
3877
+ ...(extra
3878
+ ? {
3879
+ letterSpacing: (run.letterSpacing ?? 0) + extra,
3880
+ kernAcross: true,
3881
+ }
3882
+ : null),
2819
3883
  },
2820
3884
  box,
2821
3885
  length: to - from,
@@ -2866,6 +3930,9 @@ function inlineEdges(
2866
3930
  }
2867
3931
  }
2868
3932
  box.decoration = inlineDecoration(fonts, box, s);
3933
+ if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
3934
+ PADDED_FACES.set(box, faceExtent(fonts, s));
3935
+ } else PADDED_FACES.delete(box);
2869
3936
  const left = box.marginLeft + box.borderLeft + box.padLeft;
2870
3937
  const right = box.padRight + box.borderRight + box.marginRight;
2871
3938
  return s.direction === 'rtl' ? [right, left] : [left, right];
@@ -3384,9 +4451,14 @@ function shortOfStrut(
3384
4451
  const line = lines[i];
3385
4452
  const holds = i === 0 && first ? first : strut;
3386
4453
  if (!holds) continue;
4454
+ // its room as the strut's is worked out, whichever baseline the layout
4455
+ // it is drawn with could keep (`layoutLift`)
4456
+ const text = line.texts.length === 1 ? line.texts[0] : null;
4457
+ const natural = text?.layout.lines[text.layoutLine];
4458
+ const ascent = natural ? lineAscent(natural) : line.baseline;
3387
4459
  if (
3388
- line.baseline < holds.ascent - 0.5 ||
3389
- line.height - line.baseline < holds.descent - 0.5
4460
+ ascent < holds.ascent - 0.5 ||
4461
+ line.height - ascent < holds.descent - 0.5
3390
4462
  ) {
3391
4463
  return true;
3392
4464
  }
@@ -3672,11 +4744,19 @@ class SpanMap {
3672
4744
  private _gaps: number[] | null = null;
3673
4745
  laidOut = 0;
3674
4746
 
3675
- add(documentStart: number, length: number, box: Box | null = null): void {
4747
+ add(
4748
+ documentStart: number,
4749
+ length: number,
4750
+ box: Box | null = null,
4751
+ /** No text of the document's, though a box's: a bidi control a
4752
+ * level's layout is wrapped in (`levelPieces`), which answers for
4753
+ * the element around it. */
4754
+ gap = box === null,
4755
+ ): void {
3676
4756
  this._laid.push(this.laidOut);
3677
4757
  this._doc.push(documentStart);
3678
4758
  this._boxes.push(box);
3679
- if (!box) {
4759
+ if (gap) {
3680
4760
  this._gaps ??= [];
3681
4761
  for (let i = 0; i < length; i += 1) this._gaps.push(this.laidOut + i);
3682
4762
  }
@@ -3692,6 +4772,21 @@ class SpanMap {
3692
4772
  if (gaps.length) text.gaps = gaps;
3693
4773
  }
3694
4774
 
4775
+ /** Whether an offset in the laid-out text is no text of the
4776
+ * document's: a spacer, a bidi control. */
4777
+ isGap(offset: number): boolean {
4778
+ const gaps = this._gaps;
4779
+ if (!gaps) return false;
4780
+ let lo = 0;
4781
+ let hi = gaps.length;
4782
+ while (lo < hi) {
4783
+ const mid = (lo + hi) >> 1;
4784
+ if (gaps[mid] < offset) lo = mid + 1;
4785
+ else hi = mid;
4786
+ }
4787
+ return gaps[lo] === offset;
4788
+ }
4789
+
3695
4790
  /** The text box whose text an offset in the laid-out text is. */
3696
4791
  boxAt(offset: number): Box | null {
3697
4792
  const i = this._entryAt(offset);
@@ -3837,6 +4932,183 @@ export function hungSpaces(fonts: FontsLike, run: TextRun): RegExp {
3837
4932
  return hung;
3838
4933
  }
3839
4934
 
4935
+ /** Where each run starts in their joined text. */
4936
+ function runStarts(runs: TextRun[]): number[] {
4937
+ const starts: number[] = [];
4938
+ let at = 0;
4939
+ for (const run of runs) {
4940
+ starts.push(at);
4941
+ at += run.text.length;
4942
+ }
4943
+ return starts;
4944
+ }
4945
+
4946
+ /** The run a code-unit offset in the runs' joined text falls in. */
4947
+ function runIndexAt(starts: number[], offset: number): number {
4948
+ let lo = 0;
4949
+ let hi = starts.length - 1;
4950
+ while (lo < hi) {
4951
+ const mid = (lo + hi + 1) >> 1;
4952
+ if (starts[mid] <= offset) lo = mid;
4953
+ else hi = mid - 1;
4954
+ }
4955
+ return lo;
4956
+ }
4957
+
4958
+ /**
4959
+ * The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
4960
+ * logical order and each with its advance, and whether a forced break
4961
+ * follows them. An engine strips a line's trailing white space, which is
4962
+ * right for the collapsible spaces CSS removes there, and wrong for these.
4963
+ */
4964
+ function keptAtEnd(
4965
+ natural: TextLayoutLike['lines'][number],
4966
+ runs: TextRun[],
4967
+ starts: number[],
4968
+ spans: LineText['spans'],
4969
+ fonts: FontsLike,
4970
+ ): { found: HungSpace[] | null; forced: boolean } {
4971
+ const boxAt = spans.boxAt;
4972
+ if (!boxAt || !runs.length) return { found: null, forced: false };
4973
+ const charAt = (offset: number): number => {
4974
+ const k = runIndexAt(starts, offset);
4975
+ return runs[k].text.charCodeAt(offset - starts[k]);
4976
+ };
4977
+ let end = natural.end;
4978
+ let forced = false;
4979
+ if (end > natural.start && charAt(end - 1) === 10) {
4980
+ forced = true;
4981
+ end -= 1;
4982
+ }
4983
+ let found: HungSpace[] | null = null;
4984
+ let kept = false;
4985
+ for (let at = end - 1; at >= natural.start; at -= 1) {
4986
+ const c = charAt(at);
4987
+ if (isControl(c)) continue;
4988
+ if (c !== 0x20) break;
4989
+ // a collapsible space after the last kept one is removed (4.1.3), and
4990
+ // one before a kept one hangs with it
4991
+ const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
4992
+ if (!keeps && !kept) continue;
4993
+ kept = true;
4994
+ const run = runs[runIndexAt(starts, at)];
4995
+ (found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
4996
+ }
4997
+ found?.reverse();
4998
+ return { found, forced };
4999
+ }
5000
+
5001
+ type HungSpace = NonNullable<LineText['hung']>[number];
5002
+
5003
+ /**
5004
+ * The spaces `pre-wrap` keeps that a text's line ends on, which the engine
5005
+ * hung (`keptAtEnd`): past the line's end, to its left where the layout
5006
+ * reads right to left, and their inline box's, whose background and border
5007
+ * cover them, as a browser draws them. Noted on the text (`LineText.hung`),
5008
+ * and their advance answered, with whether a forced break follows them.
5009
+ */
5010
+ function hangPreserved(
5011
+ text: LineText,
5012
+ runs: TextRun[],
5013
+ starts: number[],
5014
+ fonts: FontsLike,
5015
+ rtl: boolean,
5016
+ ): { total: number; forced: boolean } {
5017
+ const natural = text.layout.lines[text.layoutLine];
5018
+ if (!natural) return { total: 0, forced: false };
5019
+ const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
5020
+ if (!found) return { total: 0, forced };
5021
+ text.hung = found;
5022
+ placeHung(text, rtl);
5023
+ let total = 0;
5024
+ for (const space of found) total += space.width;
5025
+ return { total, forced };
5026
+ }
5027
+
5028
+ const NOTHING_HUNG = { total: 0, forced: false };
5029
+
5030
+ /** Put a text's hung spaces beside its line's end, logical order outward:
5031
+ * to the right of its glyphs, or to their left. */
5032
+ function placeHung(text: LineText, left: boolean): void {
5033
+ const natural = text.layout.lines[text.layoutLine];
5034
+ if (!text.hung || !natural) return;
5035
+ let x = left ? natural.x : natural.x + natural.width;
5036
+ for (const space of text.hung) {
5037
+ if (left) x -= space.width;
5038
+ space.x = x;
5039
+ if (!left) x += space.width;
5040
+ }
5041
+ }
5042
+
5043
+ /**
5044
+ * The runs of a layout with the spaces `pre-wrap` keeps before a forced
5045
+ * break held where they fit (`keptAtEnd`) — a kept newline, or the block's
5046
+ * end after the last line where `ends` — or null where none are. They hang
5047
+ * there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
5048
+ * which the engine gives them when they are handed over as no-break
5049
+ * spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
5050
+ * line with them. Laid out again, the lines break where they did, since
5051
+ * the spaces fit where they are. A line of a layout cannot be moved over
5052
+ * for them afterwards: its lines are drawn in one draw, from one place.
5053
+ */
5054
+ function holdAtBreaks(
5055
+ layout: TextLayoutLike,
5056
+ runs: TextRun[],
5057
+ spans: SpanMap,
5058
+ fonts: FontsLike,
5059
+ width: number,
5060
+ ends: boolean,
5061
+ ): TextRun[] | null {
5062
+ const starts = runStarts(runs);
5063
+ let held: number[] | null = null;
5064
+ const lines = layout.lines;
5065
+ for (let i = 0; i < lines.length; i += 1) {
5066
+ const natural = lines[i];
5067
+ const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
5068
+ if (!found || !(forced || (ends && i === lines.length - 1))) continue;
5069
+ let free = width - natural.width;
5070
+ for (const space of found) {
5071
+ if (space.width > free + 0.01) break;
5072
+ free -= space.width;
5073
+ (held ??= []).push(space.at);
5074
+ }
5075
+ }
5076
+ if (!held) return null;
5077
+ held.sort((a, b) => a - b);
5078
+ const out = runs.slice();
5079
+ let next = 0;
5080
+ for (let k = 0; k < runs.length && next < held.length; k += 1) {
5081
+ const start = starts[k];
5082
+ const end = start + runs[k].text.length;
5083
+ if (held[next] >= end) continue;
5084
+ const chars = runs[k].text.split('');
5085
+ while (next < held.length && held[next] < end) {
5086
+ chars[held[next] - start] = '\u00a0';
5087
+ next += 1;
5088
+ }
5089
+ out[k] = { ...runs[k], text: chars.join('') };
5090
+ }
5091
+ return out;
5092
+ }
5093
+
5094
+ /** The spaces `pre-wrap` keeps that the lines one layout made hung at
5095
+ * their ends (`hangPreserved`), noted on each line's text. */
5096
+ function hangLines(
5097
+ lines: LineBox[],
5098
+ from: number,
5099
+ runs: TextRun[],
5100
+ fonts: FontsLike,
5101
+ rtl: boolean,
5102
+ ): void {
5103
+ let starts: number[] | null = null;
5104
+ for (let i = from; i < lines.length; i += 1) {
5105
+ const text = lines[i].texts[0];
5106
+ if (!text) continue;
5107
+ starts ??= runStarts(runs);
5108
+ hangPreserved(text, runs, starts, fonts, rtl);
5109
+ }
5110
+ }
5111
+
3840
5112
  /**
3841
5113
  * A clamped paragraph with an ellipsis at the end of its last line (CSS
3842
5114
  * Overflow 4, 4.2): its text up to where that line breaks in the room the
@@ -4111,14 +5383,17 @@ function unwrappedPlacer(
4111
5383
 
4112
5384
  /**
4113
5385
  * The spacing a space is given to be laid out as one that may be spaced
4114
- * apart, too little to move a glyph. It is not nothing, because neither
4115
- * engine measures a space that is a spaced run of its own as it measures
4116
- * the same space inside its run: ntk shapes each run apart, so the kerning
4117
- * pair a space made with the letter beside it is gone (Arial's `A` and
4118
- * `T` have them), and CoreText spaces a glyph with its kerning attribute,
4119
- * which takes the place of the font's pairs. A line justified from a
4120
- * measure of its spaces as they were came out wider than its box and
4121
- * broke a word early: on macOS, in Helvetica, half of a paragraph did.
5386
+ * apart, too little to move a glyph. It is not nothing, because CoreText
5387
+ * does not measure a space that is a spaced run of its own as it measures
5388
+ * the same space inside its run: it spaces a glyph with its kerning
5389
+ * attribute, which takes the place of the font's pairs. A line justified
5390
+ * from a measure of its spaces as they were came out wider than its box
5391
+ * and broke a word early: on macOS, in Helvetica, half of a paragraph did.
5392
+ * ntk keeps the pairs a spaced space makes with the letters beside it
5393
+ * (Arial's space and `T`, its `L` and space) where the run is marked
5394
+ * `kernAcross` — spacing that justifies a line is in addition to kerning
5395
+ * (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
5396
+ * a browser's is.
4122
5397
  */
4123
5398
  const HAIR = 1e-6;
4124
5399
 
@@ -4136,6 +5411,7 @@ function spacedApart(runs: TextRun[]): TextRun[] {
4136
5411
  ...run,
4137
5412
  text: text[at],
4138
5413
  letterSpacing: (run.letterSpacing ?? 0) + HAIR,
5414
+ kernAcross: true,
4139
5415
  });
4140
5416
  piece = at + 1;
4141
5417
  }
@@ -4215,6 +5491,7 @@ function widenedAt(runs: TextRun[], extra: Map<number, number>): TextRun[] {
4215
5491
  ...run,
4216
5492
  text: text[at],
4217
5493
  letterSpacing: (run.letterSpacing ?? 0) + add,
5494
+ kernAcross: true,
4218
5495
  });
4219
5496
  piece = at + 1;
4220
5497
  }
@@ -4492,8 +5769,8 @@ function naturalLineHeight(fonts: FontsLike, style: ComputedStyle): number {
4492
5769
 
4493
5770
  /**
4494
5771
  * A block's strut: the ascent and descent of a line of nothing in its face,
4495
- * the half-leading its `line-height` adds shared above and below, as the
4496
- * text engines set a line (CSS 2.1 10.8.1).
5772
+ * the leading its `line-height` adds shared above and below as
5773
+ * `ascentOnLine` shares it (CSS 2.1 10.8.1).
4497
5774
  */
4498
5775
  export function strutOf(
4499
5776
  fonts: FontsLike,
@@ -4509,8 +5786,62 @@ export function strutOf(
4509
5786
  : style.lineHeightIsLength
4510
5787
  ? (style.lineHeight as number)
4511
5788
  : (style.lineHeight as number) * style.fontSize;
4512
- const half = (target - face.ascent - face.descent) / 2;
4513
- return { ascent: face.ascent + half, descent: face.descent + half };
5789
+ const ascent = ascentOnLine(face.ascent, face.descent, target);
5790
+ return { ascent, descent: target - ascent };
5791
+ }
5792
+
5793
+ /**
5794
+ * How far above its baseline an inline box's room on a line reaches: its
5795
+ * face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
5796
+ * rounded down to a whole pixel, the rest of the leading going below.
5797
+ *
5798
+ * The spec's halves are exact, and where one is not a whole pixel which
5799
+ * side of the grid the text lands on is the user agent's. Blink rounds the
5800
+ * half above down (`CalculateLeadingSpace`, core/layout/inline/
5801
+ * line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
5802
+ * 15 and 2px under. Split evenly, that box reached half a pixel above the
5803
+ * 12px strut beside it, whose 4px of leading divides, and every such line
5804
+ * came out 18.5px tall.
5805
+ */
5806
+ export function ascentOnLine(
5807
+ ascent: number,
5808
+ descent: number,
5809
+ height: number,
5810
+ ): number {
5811
+ return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
5812
+ }
5813
+
5814
+ /**
5815
+ * How far above its baseline a text engine's line reaches, its leading
5816
+ * shared as `ascentOnLine` shares it. The engines split it evenly (the
5817
+ * contract every text engine owes, a baseline `(height - ascent -
5818
+ * descent) / 2 + ascent` down), so a line on its own sits up to a pixel
5819
+ * lower than a browser's; one that did not say how tall its face is keeps
5820
+ * the engine's.
5821
+ */
5822
+ function lineAscent(natural: TextLayoutLike['lines'][number]): number {
5823
+ const { ascent, descent } = natural;
5824
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
5825
+ return natural.baseline - natural.y;
5826
+ }
5827
+ return ascentOnLine(ascent, descent, natural.height);
5828
+ }
5829
+
5830
+ /**
5831
+ * How far up a layout drawn whole moves for each of its lines' baselines
5832
+ * to be where `lineAscent` puts it. A layout is drawn in one batch, from
5833
+ * one origin, so it moves by one amount or not at all: where its lines
5834
+ * would move by different amounts — a face of other metrics on one — it
5835
+ * keeps the engine's baselines, which are less than a pixel out.
5836
+ */
5837
+ function layoutLift(layout: TextLayoutLike): number {
5838
+ let lift = NaN;
5839
+ for (const natural of layout.lines) {
5840
+ const own = natural.baseline - natural.y - lineAscent(natural);
5841
+ if (Number.isNaN(lift)) lift = own;
5842
+ else if (Math.abs(own - lift) > 1e-6) return 0;
5843
+ }
5844
+ return Number.isNaN(lift) ? 0 : lift;
4514
5845
  }
4515
5846
 
4516
5847
  /** Where a text `vertical-align` raised goes on its line. */
@@ -4715,13 +6046,19 @@ function ownsLeading(
4715
6046
  block: ComputedStyle,
4716
6047
  style: ComputedStyle,
4717
6048
  ): boolean {
4718
- // a bold or an italic face keeps its family's line metrics, and a
4719
- // paragraph is thick with <strong>, <em> and <a>
6049
+ // the block's own face is past at once. A bold or an italic one is not:
6050
+ // a paragraph is thick with <strong>, <em> and <a>, and most families'
6051
+ // faces share their line metrics, but not every one's — Helvetica Neue
6052
+ // Bold reaches 0.975em above its baseline to the regular's 0.952, and
6053
+ // Blink, which rounds each to a whole pixel, sets a 12px bold word on a
6054
+ // 19.2px line a pixel taller. It is measured once for its style, below.
4720
6055
  if (
4721
6056
  style.lineHeight === block.lineHeight &&
4722
6057
  style.lineHeightIsLength === block.lineHeightIsLength &&
4723
6058
  style.fontSize === block.fontSize &&
4724
- style.fontFamily === block.fontFamily
6059
+ style.fontFamily === block.fontFamily &&
6060
+ style.fontWeight === block.fontWeight &&
6061
+ style.fontStyle === block.fontStyle
4725
6062
  ) {
4726
6063
  return false;
4727
6064
  }
@@ -4859,7 +6196,7 @@ function tooNarrow(
4859
6196
  at += text.length;
4860
6197
  }
4861
6198
  if (WORD_CHAR.test(before) && WORD_CHAR.test(after)) return true;
4862
- return width > room + 0.5 && before !== '';
6199
+ return width > room + FIT_SLACK && before !== '';
4863
6200
  }
4864
6201
 
4865
6202
  const WORD_CHAR =