@react-x11/components 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/code/index.d.ts.map +1 -1
  2. package/dist/code/index.js +25 -24
  3. package/dist/code/index.js.map +1 -1
  4. package/dist/code-language/index.d.ts +1 -1
  5. package/dist/code-language/index.d.ts.map +1 -1
  6. package/dist/code-language/index.js +1 -1
  7. package/dist/code-language/index.js.map +1 -1
  8. package/dist/code-language/runs.d.ts +31 -0
  9. package/dist/code-language/runs.d.ts.map +1 -1
  10. package/dist/code-language/runs.js +105 -0
  11. package/dist/code-language/runs.js.map +1 -1
  12. package/dist/codeblock/index.d.ts +13 -0
  13. package/dist/codeblock/index.d.ts.map +1 -1
  14. package/dist/codeblock/index.js +37 -1
  15. package/dist/codeblock/index.js.map +1 -1
  16. package/dist/flow/node.d.ts +14 -6
  17. package/dist/flow/node.d.ts.map +1 -1
  18. package/dist/flow/node.js +82 -64
  19. package/dist/flow/node.js.map +1 -1
  20. package/dist/html/controls.d.ts +26 -7
  21. package/dist/html/controls.d.ts.map +1 -1
  22. package/dist/html/controls.js +28 -3
  23. package/dist/html/controls.js.map +1 -1
  24. package/dist/html/css/cascade.d.ts +32 -10
  25. package/dist/html/css/cascade.d.ts.map +1 -1
  26. package/dist/html/css/cascade.js +89 -31
  27. package/dist/html/css/cascade.js.map +1 -1
  28. package/dist/html/css/color.d.ts +19 -0
  29. package/dist/html/css/color.d.ts.map +1 -1
  30. package/dist/html/css/color.js +86 -0
  31. package/dist/html/css/color.js.map +1 -1
  32. package/dist/html/css/parse.d.ts +11 -0
  33. package/dist/html/css/parse.d.ts.map +1 -1
  34. package/dist/html/css/parse.js +114 -4
  35. package/dist/html/css/parse.js.map +1 -1
  36. package/dist/html/css/style.d.ts +45 -5
  37. package/dist/html/css/style.d.ts.map +1 -1
  38. package/dist/html/css/style.js +363 -14
  39. package/dist/html/css/style.js.map +1 -1
  40. package/dist/html/css/ua.js +22 -5
  41. package/dist/html/css/ua.js.map +1 -1
  42. package/dist/html/css/values.d.ts +10 -1
  43. package/dist/html/css/values.d.ts.map +1 -1
  44. package/dist/html/css/values.js +27 -2
  45. package/dist/html/css/values.js.map +1 -1
  46. package/dist/html/css/vars.d.ts +5 -0
  47. package/dist/html/css/vars.d.ts.map +1 -1
  48. package/dist/html/css/vars.js +2 -2
  49. package/dist/html/css/vars.js.map +1 -1
  50. package/dist/html/dom.d.ts.map +1 -1
  51. package/dist/html/dom.js +35 -2
  52. package/dist/html/dom.js.map +1 -1
  53. package/dist/html/index.d.ts.map +1 -1
  54. package/dist/html/index.js +38 -1
  55. package/dist/html/index.js.map +1 -1
  56. package/dist/html/layout/block.d.ts +11 -2
  57. package/dist/html/layout/block.d.ts.map +1 -1
  58. package/dist/html/layout/block.js +306 -25
  59. package/dist/html/layout/block.js.map +1 -1
  60. package/dist/html/layout/boxes.d.ts +61 -6
  61. package/dist/html/layout/boxes.d.ts.map +1 -1
  62. package/dist/html/layout/boxes.js +65 -16
  63. package/dist/html/layout/boxes.js.map +1 -1
  64. package/dist/html/layout/cache.d.ts +2 -2
  65. package/dist/html/layout/cache.d.ts.map +1 -1
  66. package/dist/html/layout/cache.js +8 -3
  67. package/dist/html/layout/cache.js.map +1 -1
  68. package/dist/html/layout/floats.d.ts +4 -0
  69. package/dist/html/layout/floats.d.ts.map +1 -1
  70. package/dist/html/layout/floats.js +26 -0
  71. package/dist/html/layout/floats.js.map +1 -1
  72. package/dist/html/layout/inline.d.ts +29 -2
  73. package/dist/html/layout/inline.d.ts.map +1 -1
  74. package/dist/html/layout/inline.js +1454 -125
  75. package/dist/html/layout/inline.js.map +1 -1
  76. package/dist/html/node.d.ts +33 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +608 -86
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +53 -1
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +746 -214
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/surfaces.d.ts +7 -2
  85. package/dist/html/surfaces.d.ts.map +1 -1
  86. package/dist/html/surfaces.js +16 -0
  87. package/dist/html/surfaces.js.map +1 -1
  88. package/dist/html/svg.d.ts.map +1 -1
  89. package/dist/html/svg.js +27 -20
  90. package/dist/html/svg.js.map +1 -1
  91. package/dist/internal/codelines.d.ts +42 -0
  92. package/dist/internal/codelines.d.ts.map +1 -0
  93. package/dist/internal/codelines.js +170 -0
  94. package/dist/internal/codelines.js.map +1 -0
  95. package/dist/internal/window.d.ts +22 -0
  96. package/dist/internal/window.d.ts.map +1 -1
  97. package/dist/internal/window.js +35 -8
  98. package/dist/internal/window.js.map +1 -1
  99. package/dist/markdown/index.d.ts.map +1 -1
  100. package/dist/markdown/index.js +98 -26
  101. package/dist/markdown/index.js.map +1 -1
  102. package/dist/rich-text-editor/index.d.ts.map +1 -1
  103. package/dist/rich-text-editor/index.js +3 -8
  104. package/dist/rich-text-editor/index.js.map +1 -1
  105. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  106. package/dist/rich-text-editor/markdown.js +30 -1
  107. package/dist/rich-text-editor/markdown.js.map +1 -1
  108. package/dist/rich-text-editor/replace.d.ts +13 -0
  109. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  110. package/dist/rich-text-editor/replace.js +64 -0
  111. package/dist/rich-text-editor/replace.js.map +1 -0
  112. package/dist/richtext/node.d.ts +7 -0
  113. package/dist/richtext/node.d.ts.map +1 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/terminal-output/index.d.ts.map +1 -1
  116. package/dist/terminal-output/index.js +100 -25
  117. package/dist/terminal-output/index.js.map +1 -1
  118. package/dist/terminal-output/runs.d.ts +4 -1
  119. package/dist/terminal-output/runs.d.ts.map +1 -1
  120. package/dist/terminal-output/runs.js +5 -1
  121. package/dist/terminal-output/runs.js.map +1 -1
  122. package/package.json +5 -3
  123. package/src/code/index.ts +34 -32
  124. package/src/code-language/index.ts +1 -1
  125. package/src/code-language/runs.ts +111 -1
  126. package/src/codeblock/index.ts +48 -1
  127. package/src/flow/node.ts +80 -64
  128. package/src/html/controls.ts +50 -9
  129. package/src/html/css/cascade.ts +134 -43
  130. package/src/html/css/color.ts +87 -0
  131. package/src/html/css/parse.ts +107 -4
  132. package/src/html/css/style.ts +386 -23
  133. package/src/html/css/ua.ts +22 -5
  134. package/src/html/css/values.ts +31 -3
  135. package/src/html/css/vars.ts +2 -2
  136. package/src/html/dom.ts +38 -2
  137. package/src/html/index.ts +39 -1
  138. package/src/html/layout/block.ts +360 -22
  139. package/src/html/layout/boxes.ts +98 -23
  140. package/src/html/layout/cache.ts +8 -3
  141. package/src/html/layout/floats.ts +24 -0
  142. package/src/html/layout/inline.ts +1690 -119
  143. package/src/html/node.ts +648 -101
  144. package/src/html/paint.ts +883 -283
  145. package/src/html/surfaces.ts +21 -1
  146. package/src/html/svg.ts +30 -19
  147. package/src/internal/codelines.ts +200 -0
  148. package/src/internal/window.ts +36 -8
  149. package/src/markdown/index.ts +145 -26
  150. package/src/rich-text-editor/index.ts +3 -11
  151. package/src/rich-text-editor/markdown.ts +36 -1
  152. package/src/rich-text-editor/replace.ts +85 -0
  153. package/src/richtext/node.ts +7 -0
  154. package/src/terminal-output/index.ts +127 -34
  155. package/src/terminal-output/runs.ts +16 -2
@@ -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,10 +45,13 @@ 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,
50
+ letteredAfter,
46
51
  SHADOWED_TEXT,
47
52
  SHIFTED_LINES,
48
53
  TEXT_RAISES,
54
+ transformInPlace,
49
55
  } from './boxes.js';
50
56
  import type {
51
57
  AtomicPlacement,
@@ -60,6 +66,18 @@ import type {
60
66
  import type { FloatContext } from './floats.js';
61
67
  import { tableGrid } from './grid.js';
62
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
+
63
81
  /** The slice of ntk's font manager this needs. Structural, as everywhere. */
64
82
  export interface FontsLike {
65
83
  layout(
@@ -80,6 +98,13 @@ export interface FontsLike {
80
98
  /** Whether soft wraps are made at all: false is a line to each
81
99
  * forced break, cut at `maxWidth` where `overflow` says so. ntk's. */
82
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';
83
108
  },
84
109
  ): TextLayoutLike;
85
110
  match(
@@ -108,6 +133,22 @@ const LAYOUT_RUNS = new WeakMap<TextLayoutLike, TextRun[] | number[]>();
108
133
 
109
134
  const WIDEST_WORD = new WeakMap<TextLayoutLike, number>();
110
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
+
111
152
  /**
112
153
  * The widest word between spaces in a layout's text: its runs laid out
113
154
  * again with every space a line break, at no width limit. Where a text's
@@ -135,7 +176,9 @@ export function widestWord(
135
176
  text: run.text.replace(/[ \t\u200b]+/g, '\n'),
136
177
  })),
137
178
  {},
138
- {},
179
+ // measured as the lines are fitted, or a word across two elements is
180
+ // read narrower than it fits
181
+ { fit: PARAGRAPH_FIT },
139
182
  );
140
183
  widest = 0;
141
184
  for (const line of words.lines) widest = Math.max(widest, line.width);
@@ -300,6 +343,9 @@ export interface InlineOptions {
300
343
  * — which the line is found in, a piece at a time (CSS 2.1 5.12.1).
301
344
  */
302
345
  firstLineStyle?: { style: ComputedStyle; styler: FirstLineStyler };
346
+ /** Room the first line has to hold about its baseline besides the
347
+ * strut's: a list item's marker (`MARKER_ROOM`). */
348
+ firstStrut?: InlineDecoration;
303
349
  /** Whether any box in the document paints its background through its
304
350
  * text (`BoxTree.clipText`). */
305
351
  clipText?: boolean;
@@ -431,16 +477,22 @@ function linesOf(
431
477
  CLIPPED_TEXT.add(block);
432
478
  fonts = recording(fonts);
433
479
  }
480
+ // small capitals the face does not have, made of its capitals
481
+ synthesizeSmallCaps(items, fonts);
434
482
  if (wraps(block.style)) holdNoWrap(items, block);
435
483
  // one walk for what few paragraphs have: text that casts a shadow, and a
436
484
  // tab, which only a `white-space` that keeps it leaves — so no text is
437
485
  // searched for one where it cannot be
438
486
  let tabbed = false;
439
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;
440
491
  for (const item of items) {
441
492
  if (item.kind !== 'text' || item.control) continue;
442
493
  const style = item.box.style;
443
494
  if (style.textShadow) shadowed = true;
495
+ if (style.whiteSpace === 'pre-wrap') keepsSpaces = true;
444
496
  if (
445
497
  !tabbed &&
446
498
  (style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
@@ -475,6 +527,9 @@ function linesOf(
475
527
 
476
528
  const style = block.style;
477
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;
478
533
  const base = {
479
534
  family: style.fontFamily,
480
535
  size: style.fontSize,
@@ -537,6 +592,13 @@ function linesOf(
537
592
  hasEdges && !hasAtomics && !hasOffset && spacersHold(style, items);
538
593
  // a first line in fonts of its own is found a piece at a time, in them
539
594
  const restyled = options.firstLineStyle !== undefined;
595
+ // and where one layout makes a line shorter than the strut, its text all
596
+ // smaller than the block's, the lines are made a piece at a time too,
597
+ // where every line holds it (`shortOfStrut`)
598
+ const strut = fonts ? strutOf(fonts, style) : null;
599
+ // and the first line holds what it is handed besides: a marker's room
600
+ const firstStrut = withRoom(strut, options.firstStrut);
601
+ let strutted = false;
540
602
 
541
603
  // Inline boxes' edges as spacers in one layout, where the engine left
542
604
  // every edge on the line of the content it belongs to (`layoutSpaced`);
@@ -550,20 +612,23 @@ function linesOf(
550
612
  lineHeightMul,
551
613
  align,
552
614
  fonts,
615
+ keepsSpaces,
553
616
  );
554
- if (laid) return laid;
617
+ if (laid && !shortOfStrut(laid.lines, strut, firstStrut)) return laid;
618
+ if (laid) strutted = true;
555
619
  }
556
620
 
557
621
  // The text-only, float-free, unindented case: one call, every line — or
558
622
  // one call per *chunk*, when the text is long and carries hard breaks.
559
- if (
623
+ fast: if (
560
624
  !hasAtomics &&
561
625
  !hasEdges &&
562
626
  !raised &&
563
627
  !floated &&
564
628
  !indent &&
565
629
  !alignedApart &&
566
- !restyled
630
+ !restyled &&
631
+ !strutted
567
632
  ) {
568
633
  // no atomics and no edges, so everything is text
569
634
  const textItems = items as Extract<Item, { kind: 'text' }>[];
@@ -592,11 +657,12 @@ function linesOf(
592
657
  align,
593
658
  fonts,
594
659
  { chars: Infinity, hardLines: 1, cut },
660
+ keepsSpaces,
595
661
  );
596
662
  }
597
663
  if (perLine) cut = null;
598
664
  if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
599
- return layoutChunked(
665
+ const chunked = layoutChunked(
600
666
  textItems,
601
667
  base,
602
668
  style,
@@ -604,7 +670,12 @@ function linesOf(
604
670
  lineHeightMul,
605
671
  align,
606
672
  fonts,
673
+ CHUNKS,
674
+ keepsSpaces,
607
675
  );
676
+ if (!shortOfStrut(chunked.lines, strut, firstStrut)) return chunked;
677
+ strutted = true;
678
+ break fast;
608
679
  }
609
680
  let runs: TextRun[] = [];
610
681
  let spans = new SpanMap();
@@ -627,10 +698,26 @@ function linesOf(
627
698
  align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
628
699
  direction: style.direction,
629
700
  overflowWrap: cutWrap(style, cut, wrapWords),
701
+ fit: PARAGRAPH_FIT,
630
702
  ...cut,
631
703
  };
632
704
  let layout = fonts.layout(runs, base, layoutOptions);
633
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
+ }
634
721
  // A clamp that cut the text short says so, for the clamp point after
635
722
  // it, and its last line ends in an ellipsis — as it does where the
636
723
  // clamp point falls just after the lines, more of the container after
@@ -705,12 +792,22 @@ function linesOf(
705
792
  ? unwrappedPlacer(style, options.width, false)
706
793
  : null,
707
794
  );
795
+ if (keepsSpaces) hangLines(lines, 0, runs, fonts, rtl);
796
+ // a clamp and a cut are the one layout's to make
797
+ if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
798
+ strutted = true;
799
+ break fast;
800
+ }
708
801
  return clampCut
709
802
  ? { lines, height: layout.height, width: widest, cut: true }
710
803
  : { lines, height: layout.height, width: widest };
711
804
  }
712
805
 
713
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;
714
811
  const lines: LineBox[] = [];
715
812
  let widest = 0;
716
813
  let y = 0;
@@ -751,7 +848,6 @@ function linesOf(
751
848
  deferred = [];
752
849
  };
753
850
 
754
- const strut = fonts ? strutOf(fonts, style) : null;
755
851
  // How tall a line is taken to be before it is made, for its room beside
756
852
  // the floats: the paragraph's strut, a line of its text. An item taller
757
853
  // asks over its own height, and the line made over its whole (`close`).
@@ -773,6 +869,7 @@ function linesOf(
773
869
  align: 'left',
774
870
  direction: style.direction,
775
871
  overflowWrap: wrapWords,
872
+ fit: PARAGRAPH_FIT,
776
873
  },
777
874
  }
778
875
  : null;
@@ -791,6 +888,14 @@ function linesOf(
791
888
  * which `text-align-last` aligns. */
792
889
  const close = (last = false): void => {
793
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;
794
899
  const line = finishLine(
795
900
  open,
796
901
  y,
@@ -799,13 +904,16 @@ function linesOf(
799
904
  (height) => bandAt(options, y, Math.max(height, guess)),
800
905
  last || !wraps(style) ? lastShift : restShift,
801
906
  rtl,
802
- onFirst ? lineOne.strut : strut,
907
+ lines.length
908
+ ? strut
909
+ : withRoom(onFirst ? lineOne.strut : strut, options.firstStrut),
803
910
  onFirst ? lineOne.lifts : lifts,
804
911
  (last || !wraps(style) ? justify.last : justify.rest)
805
912
  ? onFirst
806
913
  ? lineOne.justify
807
914
  : lineJustify
808
915
  : null,
916
+ levels,
809
917
  );
810
918
  if (!line) {
811
919
  open = openLine(0);
@@ -933,7 +1041,12 @@ function linesOf(
933
1041
  const raise = atomicRaise(fonts, box, (box.parent ?? block).style);
934
1042
  if (raise) placed.raise = raise;
935
1043
  open.atomics.push(placed);
936
- open.order.push({ kind: 'atomic', at: open.x, item: placed });
1044
+ open.order.push({
1045
+ kind: 'atomic',
1046
+ at: open.x,
1047
+ item: placed,
1048
+ ...(paraBidi ? { para: paraBidi.starts[index] } : null),
1049
+ });
937
1050
  open.x += outer;
938
1051
  open.hang = 0;
939
1052
  open.left = room.left;
@@ -970,13 +1083,17 @@ function linesOf(
970
1083
  const tailIsPlain =
971
1084
  !alignedApart &&
972
1085
  !onFirst &&
1086
+ !strutted &&
973
1087
  segment.nextIndex >= items.length &&
974
1088
  open.x === 0 &&
975
1089
  !open.atomics.length &&
976
1090
  !open.edges.length &&
977
1091
  !pending.length &&
978
1092
  !deferred.length &&
979
- !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
1093
+ !(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
1094
+ // and ordered alone as the paragraph orders it
1095
+ (!paraBidi ||
1096
+ orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
980
1097
  if (tailIsPlain) {
981
1098
  const tailOptions = {
982
1099
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
@@ -984,50 +1101,77 @@ function linesOf(
984
1101
  align,
985
1102
  direction: style.direction,
986
1103
  overflowWrap: wrapWords,
1104
+ fit: PARAGRAPH_FIT,
987
1105
  };
988
- let layout = fonts.layout(segment.runs, base, tailOptions);
989
- LAYOUT_RUNS.set(layout, segment.runs);
1106
+ let tailRuns = segment.runs;
1107
+ let layout = fonts.layout(tailRuns, base, tailOptions);
1108
+ LAYOUT_RUNS.set(layout, tailRuns);
1109
+ const held = keepsSpaces
1110
+ ? holdAtBreaks(
1111
+ layout,
1112
+ tailRuns,
1113
+ segment.spans,
1114
+ fonts,
1115
+ tailOptions.maxWidth ?? options.width,
1116
+ true,
1117
+ )
1118
+ : null;
1119
+ if (held) {
1120
+ tailRuns = held;
1121
+ layout = fonts.layout(tailRuns, base, tailOptions);
1122
+ LAYOUT_RUNS.set(layout, tailRuns);
1123
+ }
990
1124
  // the lines past the floats are justified as the lines beside them
991
1125
  // were (`finishLine`), in the room they have
992
1126
  layout = justifiedLayout(
993
1127
  fonts,
994
- segment.runs,
1128
+ tailRuns,
995
1129
  base,
996
1130
  tailOptions,
997
1131
  layout,
998
1132
  Math.max(1, available),
999
1133
  justify,
1000
1134
  ).layout;
1135
+ const tail: LineBox[] = [];
1136
+ const lift = layoutLift(layout);
1001
1137
  for (let i = 0; i < layout.lines.length; i += 1) {
1002
1138
  const natural = layout.lines[i];
1003
1139
  const text: LineText = {
1004
1140
  layout,
1005
1141
  layoutLine: i,
1006
1142
  drawX: band.left,
1007
- drawY: y,
1143
+ drawY: y - lift,
1008
1144
  textStart: segment.spans.documentAt(natural.start),
1009
1145
  textEnd: segment.spans.documentAt(natural.end),
1010
1146
  layoutStart: natural.start,
1011
1147
  spans: segment.spans,
1012
1148
  };
1013
1149
  segment.spans.giveGaps(text, natural.start, natural.end);
1014
- lines.push({
1150
+ tail.push({
1015
1151
  x: band.left + natural.x,
1016
1152
  y: y + natural.y,
1017
1153
  width: natural.width,
1018
1154
  height: natural.height,
1019
- baseline: natural.baseline - natural.y,
1155
+ baseline: natural.baseline - natural.y - lift,
1020
1156
  texts: [text],
1021
1157
  textStart: text.textStart,
1022
1158
  textEnd: text.textEnd,
1023
1159
  atomics: [],
1024
1160
  });
1025
- widest = Math.max(widest, natural.width);
1026
1161
  }
1027
- y += layout.height;
1028
- index = segment.nextIndex;
1029
- offset = 0;
1030
- continue;
1162
+ if (keepsSpaces) hangLines(tail, 0, tailRuns, fonts, rtl);
1163
+ // or a line at a time, where one of them is shorter than the strut
1164
+ if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
1165
+ for (const line of tail) {
1166
+ lines.push(line);
1167
+ widest = Math.max(widest, line.width);
1168
+ }
1169
+ y += layout.height;
1170
+ index = segment.nextIndex;
1171
+ offset = 0;
1172
+ continue;
1173
+ }
1174
+ strutted = true;
1031
1175
  }
1032
1176
 
1033
1177
  // One line: `maxLines: 1` cuts the layout at the first break and its
@@ -1048,27 +1192,104 @@ function linesOf(
1048
1192
  !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
1049
1193
  ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
1050
1194
  : 0;
1051
- const fragment = fonts.layout(segment.runs, lineBase, {
1052
- maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
1053
- lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
1054
- // Never aligned by the text layout: the alignment belongs to the whole
1055
- // line — its text, its atomics and its inline boxes' edges together —
1056
- // which only this loop can see, and `finishLine` shifts it all. Laid
1057
- // out aligned, the first fragment of a centred line was centred alone
1058
- // and whatever followed it on the line was placed as though it had
1059
- // not been, over it.
1060
- align: 'left',
1061
- direction: style.direction,
1062
- overflowWrap: wrapWords,
1063
- maxLines: 1,
1064
- });
1065
- LAYOUT_RUNS.set(fragment, segment.runs);
1066
- const first = fragment.lines[0];
1195
+ const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
1196
+ let fragment = fragmentLayout(
1197
+ fonts,
1198
+ segment.runs,
1199
+ lineBase,
1200
+ style,
1201
+ lineHeight,
1202
+ wrapWords,
1203
+ room - tied,
1204
+ );
1205
+ let first = fragment.lines[0];
1067
1206
  if (!first) {
1068
1207
  index = segment.nextIndex;
1069
1208
  offset = 0;
1070
1209
  continue;
1071
1210
  }
1211
+ // Did the segment wrap? ntk answers with `truncated`. A layout that does
1212
+ // not carry the flag — react-x11's Cocoa engine reports none — is asked
1213
+ // the same of its line ends instead: it wrapped if anything but
1214
+ // whitespace follows the first line. Read as "fitted", a cut fragment
1215
+ // advanced past the whole segment, and a paragraph beside a float lost
1216
+ // every line after its first. And a line that ends at a `<br>` is over
1217
+ // whether or not anything followed it in this segment: what comes next
1218
+ // — an atomic, an element's edge — is the next line's.
1219
+ let wrapped =
1220
+ breakBefore(segment.runs, first.end) ||
1221
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1222
+ // An inline box's edge is no place to break a line (CSS Text 3, 5.1):
1223
+ // where the segment runs to its end on this line, its last word runs on
1224
+ // past it, through the edges after it, into the text they stand before
1225
+ // — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
1226
+ // without the padding. Where the line may break at that text after all,
1227
+ // the edges that close boxes are still the word's, as a box's end goes
1228
+ // with the content it closes, and it is the ones that open a box that
1229
+ // go with what follows. The line holds the word and all of that or none
1230
+ // of it, so where it does not fit, the line breaks at the last place in
1231
+ // the segment it may break, and the word goes to the next line whole.
1232
+ // Where the segment has no such place, the word is the line's first,
1233
+ // and is measured with what it runs into (below). A word that ends in
1234
+ // white space hangs it, and its edges after it with it.
1235
+ let glued = 0;
1236
+ if (
1237
+ !wrapped &&
1238
+ wraps(style) &&
1239
+ lineItems[segment.nextIndex]?.kind === 'edge' &&
1240
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
1241
+ // none of it matters where the most it could come to fits: most
1242
+ // edges are nowhere near a line's end
1243
+ first.width + unbreakableBound(lineItems, segment.nextIndex) >
1244
+ room + FIT_SLACK
1245
+ ) {
1246
+ const after = textAfterEdges(lineItems, segment.nextIndex);
1247
+ glued =
1248
+ after &&
1249
+ !breaksBetween(
1250
+ fonts,
1251
+ lineBase,
1252
+ segment.runs,
1253
+ runsLength(segment.runs),
1254
+ after,
1255
+ breaksAnywhere,
1256
+ )
1257
+ ? unbreakableAfter(
1258
+ lineItems,
1259
+ segment.nextIndex,
1260
+ style,
1261
+ fonts,
1262
+ lineBase,
1263
+ )
1264
+ : closingEdges(lineItems, segment.nextIndex);
1265
+ }
1266
+ if (glued > 0 && first.width + glued > room + FIT_SLACK) {
1267
+ // the last place it may break: laid out a hair narrower than it is,
1268
+ // which the rest of the segment fits, not in the room the word
1269
+ // leaves, where it broke after its first word if the word and what
1270
+ // it runs into are wider than the line
1271
+ const earlier = fragmentLayout(
1272
+ fonts,
1273
+ segment.runs,
1274
+ lineBase,
1275
+ style,
1276
+ lineHeight,
1277
+ wrapWords,
1278
+ first.width - 0.5,
1279
+ );
1280
+ const line = earlier.lines[0];
1281
+ // and not inside a word that `overflow-wrap` cut to fit: that is for
1282
+ // a word no line holds, and the word and what it runs into may fit
1283
+ // the next line
1284
+ if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
1285
+ fragment = earlier;
1286
+ first = line;
1287
+ wrapped = true;
1288
+ glued = 0;
1289
+ }
1290
+ }
1291
+ // the first word, with what it runs into past the segment
1292
+ const word = first.width + glued;
1072
1293
  // Nothing fits beside the floats — the first word is wider than the
1073
1294
  // room they leave, and was either run past it or cut inside itself to
1074
1295
  // fit — so the line moves down to where a float ends, and tries again
@@ -1078,7 +1299,7 @@ function linesOf(
1078
1299
  if (
1079
1300
  isEmpty(open) &&
1080
1301
  available < options.width &&
1081
- tooNarrow(segment.runs, first.end, first.width, room)
1302
+ tooNarrow(segment.runs, first.end, word, room)
1082
1303
  ) {
1083
1304
  const below = belowFloats(options, y, guess);
1084
1305
  if (below !== null) {
@@ -1088,30 +1309,47 @@ function linesOf(
1088
1309
  }
1089
1310
  // An inline box's opening edge goes with the first word after it: where
1090
1311
  // the word does not fit the room the edge leaves, both go to the next
1091
- // line, rather than the edge being left at this one's end. And a word
1092
- // that may start a line — after a space the line ends on, or after an
1093
- // atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
1094
- // next line whole where it does not fit what is left of this one,
1095
- // rather than being broken inside itself to fit it: the text after an
1096
- // inline-block, or after a float it was cut at, was its first letter
1097
- // at the line's end and the rest on the next.
1312
+ // line, rather than the edge being left at this one's end — where the
1313
+ // line may break there at all. The edge is no place to break (CSS Text
1314
+ // 3, 5.1), so that is where the line may break between what it ends on
1315
+ // and the word: after white space or an atomic, or where the text
1316
+ // engine breaks the two run together (`breaksBetween`). `ab<span
1317
+ // style="padding: 0 4px">cd</span>` is one word, and a line too narrow
1318
+ // for it runs past its end, as it does for the word without the
1319
+ // padding; broken before the span, a float's min-content width was a
1320
+ // letter wide, and it came out as wide as its room.
1321
+ // And a word that may start a line — after a space the line ends on,
1322
+ // or after an atomic, which has a break after it (CSS Text 3, 5.1) —
1323
+ // goes to the next line whole where it does not fit what is left of
1324
+ // this one, rather than being broken inside itself to fit it: the text
1325
+ // after an inline-block, or after a float it was cut at, was its first
1326
+ // letter at the line's end and the rest on the next.
1098
1327
  const startsLine =
1099
1328
  wraps(style) &&
1100
1329
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
1101
1330
  if (
1102
1331
  !isEmpty(open) &&
1103
1332
  (pendingWidth > 0
1104
- ? tooNarrow(segment.runs, first.end, first.width, room)
1333
+ ? tooNarrow(segment.runs, first.end, word, room) &&
1334
+ breaksAfterLine(
1335
+ open,
1336
+ style,
1337
+ fonts,
1338
+ lineBase,
1339
+ segment.runs,
1340
+ breaksAnywhere,
1341
+ )
1105
1342
  : startsLine &&
1106
1343
  // run past the room — its white space is no part of the width —
1107
1344
  // or cut inside itself to fit it
1108
- (first.width > room + 0.5 ||
1109
- tooNarrow(segment.runs, first.end, first.width, room)))
1345
+ (word > room + FIT_SLACK ||
1346
+ tooNarrow(segment.runs, first.end, word, room)))
1110
1347
  ) {
1111
1348
  close();
1112
1349
  continue;
1113
1350
  }
1114
1351
  placePending(band.left);
1352
+ const joins = joinsOnto(open, segment.runs, first.start);
1115
1353
  const placed: LineText = {
1116
1354
  layout: fragment,
1117
1355
  layoutLine: 0,
@@ -1124,28 +1362,35 @@ function linesOf(
1124
1362
  spans: segment.spans,
1125
1363
  };
1126
1364
  segment.spans.giveGaps(placed, first.start, first.end);
1365
+ if (joins !== undefined) JOINS.set(placed, joins);
1127
1366
  open.texts.push(placed);
1128
1367
  open.order.push({
1129
1368
  kind: 'text',
1130
1369
  at: open.x,
1131
1370
  item: placed,
1132
1371
  reads: readingOf(segment.runs, first.start, first.end),
1372
+ ...(paraBidi
1373
+ ? {
1374
+ from: {
1375
+ para: paraBidi.starts[index] + offset,
1376
+ runs: segment.runs,
1377
+ spans: segment.spans,
1378
+ },
1379
+ }
1380
+ : null),
1133
1381
  });
1134
1382
  open.x += first.width;
1135
1383
  open.left = band.left;
1136
1384
  open.hang = 0;
1385
+ // the spaces `pre-wrap` keeps that it ends on, which the engine hung
1386
+ const hung = keepsSpaces
1387
+ ? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
1388
+ : NOTHING_HUNG;
1389
+ /** Before a forced break, the ones that fit take room (4.1.3). */
1390
+ const holdHung = (): void => {
1391
+ open.x += Math.min(hung.total, Math.max(0, available - open.x));
1392
+ };
1137
1393
 
1138
- // Did the segment wrap? ntk answers with `truncated`. A layout that does
1139
- // not carry the flag — react-x11's Cocoa engine reports none — is asked
1140
- // the same of its line ends instead: it wrapped if anything but
1141
- // whitespace follows the first line. Read as "fitted", a cut fragment
1142
- // advanced past the whole segment, and a paragraph beside a float lost
1143
- // every line after its first. And a line that ends at a `<br>` is over
1144
- // whether or not anything followed it in this segment: what comes next
1145
- // — an atomic, an element's edge — is the next line's.
1146
- const wrapped =
1147
- breakBefore(segment.runs, first.end) ||
1148
- (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1149
1394
  if (!wrapped) {
1150
1395
  // It fitted: the cursor stays on this line for whatever comes next.
1151
1396
  //
@@ -1159,7 +1404,10 @@ function linesOf(
1159
1404
  const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
1160
1405
  let trailing = '';
1161
1406
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
1162
- const text = segment.runs[i].text;
1407
+ // through the bidi controls after them, which take no room: the
1408
+ // engine strips the spaces before one as well
1409
+ const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
1410
+ if (!text) continue;
1163
1411
  const m = hung.exec(text);
1164
1412
  if (!m) break;
1165
1413
  trailing = m[0] + trailing;
@@ -1167,18 +1415,26 @@ function linesOf(
1167
1415
  }
1168
1416
  if (trailing) {
1169
1417
  // and noted: if what follows goes to the next line after all,
1170
- // these spaces end this one, where CSS removes them (16.6.1)
1418
+ // these spaces end this one, where CSS removes them (16.6.1).
1419
+ // As wide as the engine says they make its line where the line
1420
+ // goes on, which a layout that fits as a browser does rounds with
1421
+ // the text they end, once (`advance`): the text rounded up to a
1422
+ // 64th and the spaces added after it came to more
1171
1423
  open.hang =
1172
- spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1173
- trailing.length;
1424
+ first.advance !== undefined
1425
+ ? first.advance - first.width
1426
+ : spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1427
+ trailing.length;
1174
1428
  open.x += open.hang;
1175
1429
  }
1176
- }
1430
+ } else if (hung.total) holdHung();
1177
1431
  index = segment.nextIndex;
1178
1432
  offset = 0;
1179
1433
  continue;
1180
1434
  }
1181
- close(breakBefore(segment.runs, first.end));
1435
+ const forced = breakBefore(segment.runs, first.end);
1436
+ if (forced && hung.total) holdHung();
1437
+ close(forced);
1182
1438
  const advanced = advance(items, index, offset, first.end);
1183
1439
  index = advanced.index;
1184
1440
  offset = advanced.offset;
@@ -1201,6 +1457,36 @@ function linesOf(
1201
1457
 
1202
1458
  const EMPTY: InlineResult = { lines: [], height: 0, width: 0 };
1203
1459
 
1460
+ /** The first line of a segment's text in a width, as the line at a time
1461
+ * lays one out (`linesOf`). */
1462
+ function fragmentLayout(
1463
+ fonts: FontsLike,
1464
+ runs: TextRun[],
1465
+ base: Record<string, unknown>,
1466
+ style: ComputedStyle,
1467
+ lineHeight: number,
1468
+ overflowWrap: 'normal' | 'break-word',
1469
+ width: number,
1470
+ ): TextLayoutLike {
1471
+ const laid = fonts.layout(runs, base, {
1472
+ maxWidth: wraps(style) ? Math.max(1, width) : undefined,
1473
+ lineHeight,
1474
+ // Never aligned by the text layout: the alignment belongs to the whole
1475
+ // line — its text, its atomics and its inline boxes' edges together —
1476
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1477
+ // out aligned, the first fragment of a centred line was centred alone
1478
+ // and whatever followed it on the line was placed as though it had not
1479
+ // been, over it.
1480
+ align: 'left',
1481
+ direction: style.direction,
1482
+ overflowWrap,
1483
+ fit: PARAGRAPH_FIT,
1484
+ maxLines: 1,
1485
+ });
1486
+ LAYOUT_RUNS.set(laid, runs);
1487
+ return laid;
1488
+ }
1489
+
1204
1490
  /**
1205
1491
  * Which line each float a paragraph placed stands beside: the one whose top
1206
1492
  * it went at, whose content moved over for it — added up on the line, for
@@ -1327,9 +1613,10 @@ function layoutSpaced(
1327
1613
  lineHeightMul: number,
1328
1614
  align: string,
1329
1615
  fonts: FontsLike,
1616
+ keepsSpaces: boolean,
1330
1617
  ): InlineResult | null {
1331
1618
  const wrapWords = overflowWrapOf(style, items);
1332
- const runs: TextRun[] = [];
1619
+ let runs: TextRun[] = [];
1333
1620
  const spans = new SpanMap();
1334
1621
  const spacers: { at: number; edge: Extract<Item, { kind: 'edge' }> }[] = [];
1335
1622
  let doc = 0;
@@ -1360,9 +1647,18 @@ function layoutSpaced(
1360
1647
  align,
1361
1648
  direction: style.direction,
1362
1649
  overflowWrap: wrapWords,
1650
+ fit: PARAGRAPH_FIT,
1363
1651
  };
1364
1652
  let layout = fonts.layout(runs, base, layoutOptions);
1365
1653
  LAYOUT_RUNS.set(layout, runs);
1654
+ const held = keepsSpaces
1655
+ ? holdAtBreaks(layout, runs, spans, fonts, width, true)
1656
+ : null;
1657
+ if (held) {
1658
+ runs = held;
1659
+ layout = fonts.layout(runs, base, layoutOptions);
1660
+ LAYOUT_RUNS.set(layout, runs);
1661
+ }
1366
1662
  // justified with its spacers in it, which are edges and not spaces
1367
1663
  layout = justifiedLayout(
1368
1664
  fonts,
@@ -1375,6 +1671,7 @@ function layoutSpaced(
1375
1671
  new Set(spacers.map((spacer) => spacer.at)),
1376
1672
  ).layout;
1377
1673
  const place = wraps(style) ? null : unwrappedPlacer(style, width);
1674
+ const lift = layoutLift(layout);
1378
1675
  let offsets: number[] | null = null;
1379
1676
  const lines: LineBox[] = [];
1380
1677
  let widest = 0;
@@ -1405,7 +1702,7 @@ function layoutSpaced(
1405
1702
  layout,
1406
1703
  layoutLine: i,
1407
1704
  drawX: dx,
1408
- drawY: 0,
1705
+ drawY: -lift,
1409
1706
  textStart: spans.documentAt(natural.start),
1410
1707
  textEnd: spans.documentAt(natural.end),
1411
1708
  layoutStart: natural.start,
@@ -1417,7 +1714,7 @@ function layoutSpaced(
1417
1714
  y: natural.y,
1418
1715
  width: natural.width,
1419
1716
  height: natural.height,
1420
- baseline: natural.baseline - natural.y,
1717
+ baseline: natural.baseline - natural.y - lift,
1421
1718
  texts: [text],
1422
1719
  textStart: text.textStart,
1423
1720
  textEnd: text.textEnd,
@@ -1426,6 +1723,7 @@ function layoutSpaced(
1426
1723
  });
1427
1724
  widest = Math.max(widest, natural.width);
1428
1725
  }
1726
+ if (keepsSpaces) hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
1429
1727
  return { lines, height: layout.height, width: widest };
1430
1728
  }
1431
1729
 
@@ -1479,6 +1777,7 @@ function emitLayout(
1479
1777
  place: LinePlacer | null = null,
1480
1778
  ): number {
1481
1779
  let widest = 0;
1780
+ const lift = layoutLift(layout);
1482
1781
  for (let i = 0; i < layout.lines.length; i += 1) {
1483
1782
  const natural = layout.lines[i];
1484
1783
  const dx = place ? place(natural) : 0;
@@ -1486,7 +1785,7 @@ function emitLayout(
1486
1785
  layout,
1487
1786
  layoutLine: i,
1488
1787
  drawX: xOff + dx,
1489
- drawY: yOff,
1788
+ drawY: yOff - lift,
1490
1789
  textStart: spans.documentAt(natural.start),
1491
1790
  textEnd: spans.documentAt(natural.end),
1492
1791
  layoutStart: natural.start,
@@ -1498,7 +1797,7 @@ function emitLayout(
1498
1797
  y: yOff + natural.y,
1499
1798
  width: natural.width,
1500
1799
  height: natural.height,
1501
- baseline: natural.baseline - natural.y,
1800
+ baseline: natural.baseline - natural.y - lift,
1502
1801
  texts: [text],
1503
1802
  textStart: text.textStart,
1504
1803
  textEnd: text.textEnd,
@@ -1554,6 +1853,7 @@ function layoutChunked(
1554
1853
  align: string,
1555
1854
  fonts: FontsLike,
1556
1855
  chunking: Chunking = CHUNKS,
1856
+ keepsSpaces = false,
1557
1857
  ): InlineResult {
1558
1858
  const wrapWords = overflowWrapOf(style, items);
1559
1859
  const lines: LineBox[] = [];
@@ -1567,7 +1867,7 @@ function layoutChunked(
1567
1867
 
1568
1868
  const { cut } = chunking;
1569
1869
  const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
1570
- const flush = (): void => {
1870
+ const flush = (last = false): void => {
1571
1871
  if (!chunkRuns.length) return;
1572
1872
  const chunkOptions = {
1573
1873
  maxWidth: wraps(style) || cut ? width : undefined,
@@ -1575,10 +1875,26 @@ function layoutChunked(
1575
1875
  align,
1576
1876
  direction: style.direction,
1577
1877
  overflowWrap: cutWrap(style, cut, wrapWords),
1878
+ fit: PARAGRAPH_FIT,
1578
1879
  ...cut,
1579
1880
  };
1580
1881
  let layout = fonts.layout(chunkRuns, base, chunkOptions);
1581
1882
  LAYOUT_RUNS.set(layout, chunkRuns);
1883
+ const held = keepsSpaces
1884
+ ? holdAtBreaks(
1885
+ layout,
1886
+ chunkRuns,
1887
+ chunkSpans,
1888
+ fonts,
1889
+ chunkOptions.maxWidth ?? width,
1890
+ last && !cut,
1891
+ )
1892
+ : null;
1893
+ if (held) {
1894
+ chunkRuns = held;
1895
+ layout = fonts.layout(chunkRuns, base, chunkOptions);
1896
+ LAYOUT_RUNS.set(layout, chunkRuns);
1897
+ }
1582
1898
  // a chunk ends at a forced break or at the paragraph's end, which is
1583
1899
  // where `justifiedRuns` takes a chunk's last line to end
1584
1900
  layout = justifiedLayout(
@@ -1590,10 +1906,14 @@ function layoutChunked(
1590
1906
  width,
1591
1907
  justification(style),
1592
1908
  ).layout;
1909
+ const from = lines.length;
1593
1910
  widest = Math.max(
1594
1911
  widest,
1595
1912
  emitLayout(layout, chunkSpans, 0, y, lines, place),
1596
1913
  );
1914
+ if (keepsSpaces) {
1915
+ hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
1916
+ }
1597
1917
  y += layout.height;
1598
1918
  chunkRuns = [];
1599
1919
  chunkSpans = new SpanMap();
@@ -1658,7 +1978,7 @@ function layoutChunked(
1658
1978
  text = text.slice(cut);
1659
1979
  }
1660
1980
  }
1661
- flush();
1981
+ flush(true);
1662
1982
  return { lines, height: y, width: widest };
1663
1983
  }
1664
1984
 
@@ -1683,8 +2003,26 @@ interface OpenLine {
1683
2003
  }
1684
2004
 
1685
2005
  type Placed =
1686
- | { kind: 'text'; at: number; item: LineText; reads: Reading }
1687
- | { kind: 'atomic'; at: number; item: AtomicPlacement }
2006
+ | {
2007
+ kind: 'text';
2008
+ at: number;
2009
+ item: LineText;
2010
+ reads: Reading;
2011
+ /** Where the text was laid out from, in a paragraph with bidi levels
2012
+ * (`ParagraphBidi`): its segment's place in the paragraph's text,
2013
+ * runs and spans, which `levelPieces` splits it by. */
2014
+ from?: { para: number; runs: TextRun[]; spans: SpanMap };
2015
+ /** Its UAX #9 level, once `levelPieces` has found it one. */
2016
+ level?: number;
2017
+ }
2018
+ | {
2019
+ kind: 'atomic';
2020
+ at: number;
2021
+ item: AtomicPlacement;
2022
+ /** Its object replacement character's place in the paragraph. */
2023
+ para?: number;
2024
+ level?: number;
2025
+ }
1688
2026
  | { kind: 'edge'; at: number; item: EdgePlacement };
1689
2027
 
1690
2028
  /** Which way a fragment's letters read: all one way, both, or neither —
@@ -1713,6 +2051,271 @@ function breaksAtEnd(open: OpenLine, style: ComputedStyle): boolean {
1713
2051
  );
1714
2052
  }
1715
2053
 
2054
+ /**
2055
+ * Whether a line may break before an inline box's opening edge, between
2056
+ * what the line has got to and `next`, the text after the edge: after an
2057
+ * atomic, or after the white space the line ends on, as `breaksAtEnd`
2058
+ * says, or where the text engine breaks the line's last text and `next`
2059
+ * run together (`breaksBetween`). An edge on the way is no place to break
2060
+ * of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
2061
+ * yet has none to hold on to.
2062
+ */
2063
+ function breaksAfterLine(
2064
+ open: OpenLine,
2065
+ style: ComputedStyle,
2066
+ fonts: FontsLike,
2067
+ base: Record<string, unknown>,
2068
+ next: readonly TextRun[],
2069
+ anywhere: boolean,
2070
+ ): boolean {
2071
+ if (!wraps(style)) return false;
2072
+ if (open.hang > 0) return true;
2073
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
2074
+ const placed = open.order[i];
2075
+ if (placed.kind === 'edge') continue;
2076
+ if (placed.kind === 'atomic') return true;
2077
+ const text = placed.item;
2078
+ const runs = LAYOUT_RUNS.get(text.layout);
2079
+ if (!runs || (runs.length > 0 && typeof runs[0] === 'number')) return true;
2080
+ const end = text.layout.lines[text.layoutLine].end;
2081
+ return breaksBetween(fonts, base, runs as TextRun[], end, next, anywhere);
2082
+ }
2083
+ return true;
2084
+ }
2085
+
2086
+ /**
2087
+ * How wide the edges are between the text the open line ends on and text
2088
+ * starting at `start` in `runs`, where the one runs on into the other
2089
+ * (`JOINS`): neither is white space at the join, and no atomic, which has a
2090
+ * break before and after it, is between them. Undefined where they do not
2091
+ * join, or the line has no text yet. Letters on either side are taken to
2092
+ * join whatever they are, which may count an ideograph's join that the
2093
+ * engine would break at: it makes the bound it is for a larger one, never a
2094
+ * smaller.
2095
+ */
2096
+ function joinsOnto(
2097
+ open: OpenLine,
2098
+ runs: readonly TextRun[],
2099
+ start: number,
2100
+ ): number | undefined {
2101
+ if (open.hang > 0) return undefined;
2102
+ const next = charAt(runs, start);
2103
+ if (next === '' || SPACE.test(next)) return undefined;
2104
+ let edges = 0;
2105
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
2106
+ const placed = open.order[i];
2107
+ if (placed.kind === 'edge') {
2108
+ edges += placed.item.width;
2109
+ continue;
2110
+ }
2111
+ if (placed.kind === 'atomic') return undefined;
2112
+ const text = placed.item;
2113
+ const before = LAYOUT_RUNS.get(text.layout);
2114
+ if (!before || (before.length > 0 && typeof before[0] === 'number')) {
2115
+ return undefined;
2116
+ }
2117
+ const last = charAt(
2118
+ before as TextRun[],
2119
+ text.layout.lines[text.layoutLine].end - 1,
2120
+ );
2121
+ return last === '' || SPACE.test(last) ? undefined : edges;
2122
+ }
2123
+ return undefined;
2124
+ }
2125
+
2126
+ /**
2127
+ * Whether a line may break between two texts an inline box's edge stands
2128
+ * between — the text of `before` up to the code unit `end`, and `after` —
2129
+ * which is where it may break them run together, the edge being no place
2130
+ * to break (CSS Text 3, 5.1). After white space, and not before it, where
2131
+ * the break is after that white space instead (UAX #14, LB7). Never between
2132
+ * two letters or digits of the alphabets whose words break only at spaces,
2133
+ * which is every edge in running text, and needs no asking. Anything else
2134
+ * — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
2135
+ * of the text engine: the two words, laid out at no width, break at every
2136
+ * place a line may, which is UAX #14 as the `linebreak` package has it on
2137
+ * both backends — ntk breaks every line with it, and react-x11's CoreText
2138
+ * engine a text at no width. So an edge after a hyphen, or between two
2139
+ * ideographs, is a place to break exactly where the same text with no edge
2140
+ * in it is one; a list of classes here would be a third opinion.
2141
+ *
2142
+ * Counted, not located: the words run together break into as many pieces
2143
+ * as the two apart where they break between them, and one fewer where the
2144
+ * last piece of the one runs on into the first of the other. The line
2145
+ * offsets are no answer, as they are not the text's on every backend: the
2146
+ * CoreText engine lays out a copy with a line break put in at each place,
2147
+ * and its offsets are that copy's.
2148
+ */
2149
+ function breaksBetween(
2150
+ fonts: FontsLike,
2151
+ base: Record<string, unknown>,
2152
+ before: readonly TextRun[],
2153
+ end: number,
2154
+ after: readonly TextRun[],
2155
+ anywhere: boolean,
2156
+ ): boolean {
2157
+ const last = charAt(before, end - 1);
2158
+ const next = charAt(after, 0);
2159
+ // nothing to hold the two together
2160
+ if (last === '' || next === '') return true;
2161
+ if (SPACE.test(last)) return true;
2162
+ if (SPACE.test(next)) return false;
2163
+ if (anywhere) return true;
2164
+ if (HOLDS.test(last) && HOLDS.test(next)) return false;
2165
+ if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next)) return false;
2166
+ const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
2167
+ const head = sliceRuns(after, 0, firstSpace(after));
2168
+ const pieces = (runs: TextRun[]): number =>
2169
+ fonts.layout(runs, base, {
2170
+ maxWidth: MIN_CONTENT_WIDTH,
2171
+ overflowWrap: 'normal',
2172
+ }).lines.length;
2173
+ return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
2174
+ }
2175
+
2176
+ /** The width a text is laid out at to break it at every place it may
2177
+ * break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
2178
+ * `MIN_CONTENT_PROBE`). */
2179
+ const MIN_CONTENT_WIDTH = 0;
2180
+
2181
+ /** White space a line breaks after. */
2182
+ const SPACE = /^[ \t\n]$/;
2183
+
2184
+ /** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
2185
+ * marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
2186
+ * LB25, LB28). */
2187
+ const HOLDS =
2188
+ /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
2189
+
2190
+ /** What no line breaks after, whatever follows: an opening bracket, a
2191
+ * straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
2192
+ const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
2193
+
2194
+ /** What no line breaks before, whatever precedes: a closing bracket, a
2195
+ * stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
2196
+ * LB13, LB19) — the punctuation after a link or a `<code>`, which needs
2197
+ * no asking either. */
2198
+ const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
2199
+
2200
+ /** The code unit at an offset into runs' joined text, or '' outside it. */
2201
+ function charAt(runs: readonly TextRun[], at: number): string {
2202
+ if (at < 0) return '';
2203
+ let from = 0;
2204
+ for (const run of runs) {
2205
+ const next = from + run.text.length;
2206
+ if (at < next) return run.text[at - from];
2207
+ from = next;
2208
+ }
2209
+ return '';
2210
+ }
2211
+
2212
+ /** Where the last white space before a code-unit offset is, or -1. */
2213
+ function lastSpace(runs: readonly TextRun[], end: number): number {
2214
+ let found = -1;
2215
+ let from = 0;
2216
+ for (const run of runs) {
2217
+ if (from >= end) break;
2218
+ const text = run.text.slice(0, end - from);
2219
+ const at = Math.max(
2220
+ text.lastIndexOf(' '),
2221
+ text.lastIndexOf('\t'),
2222
+ text.lastIndexOf('\n'),
2223
+ );
2224
+ if (at >= 0) found = from + at;
2225
+ from += run.text.length;
2226
+ }
2227
+ return found;
2228
+ }
2229
+
2230
+ /** Where the first white space in runs' joined text is, or its length. */
2231
+ function firstSpace(runs: readonly TextRun[]): number {
2232
+ let from = 0;
2233
+ for (const run of runs) {
2234
+ const at = run.text.search(/[ \t\n]/);
2235
+ if (at >= 0) return from + at;
2236
+ from += run.text.length;
2237
+ }
2238
+ return from;
2239
+ }
2240
+
2241
+ function runsLength(runs: readonly TextRun[]): number {
2242
+ let length = 0;
2243
+ for (const run of runs) length += run.text.length;
2244
+ return length;
2245
+ }
2246
+
2247
+ /**
2248
+ * The text inline boxes' edges from `from` on stand before, up to its first
2249
+ * white space: what the text before the edges runs on into. Null where an
2250
+ * atomic comes first, which has a break before it, or nothing does.
2251
+ */
2252
+ function textAfterEdges(
2253
+ items: readonly Item[],
2254
+ from: number,
2255
+ ): TextRun[] | null {
2256
+ const runs: TextRun[] = [];
2257
+ for (let i = from; i < items.length; i += 1) {
2258
+ const item = items[i];
2259
+ if (item.kind === 'edge' || item.kind === 'float') continue;
2260
+ if (item.kind === 'atomic') break;
2261
+ const text = item.run.text;
2262
+ const stop = text.search(/[ \t\n]/);
2263
+ if (stop < 0) {
2264
+ runs.push(item.run);
2265
+ continue;
2266
+ }
2267
+ runs.push({ ...item.run, text: text.slice(0, stop + 1) });
2268
+ break;
2269
+ }
2270
+ return runs.length ? runs : null;
2271
+ }
2272
+
2273
+ /**
2274
+ * The most `unbreakableAfter` could come to, with no layout: the edges on
2275
+ * the way, and the text up to its first white space at half again its size
2276
+ * a character, which no glyph a document sets words in is wider than, and
2277
+ * its letter spacing (`longestWord`'s estimate).
2278
+ */
2279
+ function unbreakableBound(items: readonly Item[], from: number): number {
2280
+ let width = 0;
2281
+ for (let i = from; i < items.length; i += 1) {
2282
+ const item = items[i];
2283
+ if (item.kind === 'float') continue;
2284
+ if (item.kind === 'edge') {
2285
+ width += Math.max(0, item.width);
2286
+ continue;
2287
+ }
2288
+ if (item.kind === 'atomic') break;
2289
+ const run = item.run;
2290
+ const stop = run.text.search(/[ \t\n]/);
2291
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
2292
+ width += (stop < 0 ? run.text.length : stop) * per;
2293
+ if (stop >= 0) break;
2294
+ }
2295
+ return width;
2296
+ }
2297
+
2298
+ /** How wide the edges from `from` on are that close inline boxes, up to
2299
+ * one that opens a box or anything but an edge or a float. */
2300
+ function closingEdges(items: readonly Item[], from: number): number {
2301
+ let width = 0;
2302
+ for (let i = from; i < items.length; i += 1) {
2303
+ const item = items[i];
2304
+ if (item.kind === 'float') continue;
2305
+ if (item.kind !== 'edge' || item.side !== 'end') break;
2306
+ width += item.width;
2307
+ }
2308
+ return width;
2309
+ }
2310
+
2311
+ /** Whether a code-unit offset into runs' joined text is between two
2312
+ * letters or digits: inside a word, where only `overflow-wrap` breaks. */
2313
+ function insideWord(runs: readonly TextRun[], at: number): boolean {
2314
+ return (
2315
+ WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at))
2316
+ );
2317
+ }
2318
+
1716
2319
  /**
1717
2320
  * How wide the content from `from` on is up to where its line may break:
1718
2321
  * its text up to a space it may break at — one `holdNoWrap` left a space —
@@ -1751,7 +2354,9 @@ function unbreakableAfter(
1751
2354
  if (stop > 0) runs.push({ ...item.run, text: text.slice(0, stop) });
1752
2355
  break;
1753
2356
  }
1754
- return runs.length ? width + fonts.layout(runs, base, {}).width : width;
2357
+ return runs.length
2358
+ ? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
2359
+ : width;
1755
2360
  }
1756
2361
 
1757
2362
  /**
@@ -1769,11 +2374,16 @@ function finishLine(
1769
2374
  strut: InlineDecoration | null,
1770
2375
  lifts: Lifts | null = null,
1771
2376
  justify: LineJustify | null = null,
2377
+ /** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
2378
+ levels: LineLevels | null = null,
1772
2379
  ): LineBox | null {
1773
2380
  if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1774
2381
  return null;
1775
2382
  }
1776
- const reordered = open.order.some((p) => needsOrdering(p, rtl));
2383
+ const reordered = levels
2384
+ ? levelPieces(open, levels) ||
2385
+ open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
2386
+ : open.order.some((p) => needsOrdering(p, rtl));
1777
2387
  if (reordered) reorderLine(open, rtl);
1778
2388
  // Every line box starts with the block's strut, its face at its line
1779
2389
  // height, so a line of images alone is still as tall as `line-height`
@@ -1822,7 +2432,9 @@ function finishLine(
1822
2432
  continue;
1823
2433
  }
1824
2434
  const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1825
- const own = natural.baseline - natural.y;
2435
+ // with its leading shared as the strut's is: it is drawn on the line's
2436
+ // baseline, wherever its engine put its own
2437
+ const own = lineAscent(natural);
1826
2438
  ascent = Math.max(ascent, own);
1827
2439
  descent = Math.max(descent, natural.height - own);
1828
2440
  }
@@ -1979,10 +2591,8 @@ function finishLine(
1979
2591
  * UAX #9's L2 over pieces rather than characters: a fragment is in order
1980
2592
  * inside itself already — the engine laid it out bidirectionally — so what
1981
2593
  * is left to order is the fragments and the atomics between them, each at
1982
- * a level — a fragment the direction its letters read in, or the
1983
- * paragraph's where they read both ways, and a neutral (an atomic, a
1984
- * fragment of spaces and digits) its neighbours' where they agree and the
1985
- * paragraph's where they do not (N1, N2).
2594
+ * a level: the paragraph's own, where it has them (`levelPieces`), and
2595
+ * otherwise the one it reads at (`readingLevels`).
1986
2596
  *
1987
2597
  * Then each element's edges, innermost first, around what of it is on the
1988
2598
  * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
@@ -1998,25 +2608,16 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
1998
2608
  if (!n) return;
1999
2609
  const content: number[] = [];
2000
2610
  for (let i = 0; i < n; i += 1) if (order[i].kind !== 'edge') content.push(i);
2001
- const strong = content.map((i): boolean | null => {
2611
+ // UAX #9's levels where the paragraph's are known (`levelPieces`), each
2612
+ // piece at one of them; its reading otherwise
2613
+ const given = content.map((i) => {
2002
2614
  const p = order[i];
2003
- if (p.kind !== 'text' || p.reads === null) return null;
2004
- return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2005
- });
2006
- const strongAt = (from: number, step: number): boolean => {
2007
- for (let k = from + step; k >= 0 && k < content.length; k += step) {
2008
- if (strong[k] !== null) return strong[k]!;
2009
- }
2010
- return rtl;
2011
- };
2012
- const isRtl = strong.map((r, k) => {
2013
- if (r !== null) return r;
2014
- const before = strongAt(k, -1);
2015
- return before === strongAt(k, 1) ? before : rtl;
2615
+ return p.kind === 'edge' ? undefined : p.level;
2016
2616
  });
2017
- // R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
2018
- // in a right-to-left one
2019
- const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
2617
+ const levels = given.every((level) => level !== undefined)
2618
+ ? (given as number[])
2619
+ : readingLevels(order, content, rtl);
2620
+ const isRtl = levels.map((level) => (level & 1) === 1);
2020
2621
  const visual = content.map((_, k) => k);
2021
2622
  for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
2022
2623
  for (let a = 0; a < visual.length;) {
@@ -2087,6 +2688,8 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
2087
2688
  const k = content.indexOf(i);
2088
2689
  if (isRtl[k] && natural) dx += Math.max(0, room - natural.width);
2089
2690
  piece.item.drawX += dx;
2691
+ // and the spaces it hung are on the side of it its room is left on
2692
+ if (piece.item.hung) placeHung(piece.item, isRtl[k]);
2090
2693
  } else {
2091
2694
  piece.item.x += dx;
2092
2695
  }
@@ -2094,6 +2697,473 @@ function reorderLine(open: OpenLine, rtl: boolean): void {
2094
2697
  }
2095
2698
  }
2096
2699
 
2700
+ /**
2701
+ * The levels of a line's pieces from which way each reads, where the
2702
+ * paragraph's are not known: a fragment the direction its letters read in,
2703
+ * or the paragraph's where they read both ways, and a neutral — an atomic, a
2704
+ * fragment of spaces and digits — its neighbours' where they agree and the
2705
+ * paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
2706
+ * L is 0 in a left-to-right one and 2 in a right-to-left one.
2707
+ */
2708
+ function readingLevels(
2709
+ order: Placed[],
2710
+ content: number[],
2711
+ rtl: boolean,
2712
+ ): number[] {
2713
+ const strong = content.map((i): boolean | null => {
2714
+ const p = order[i];
2715
+ if (p.kind !== 'text' || p.reads === null) return null;
2716
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2717
+ });
2718
+ const strongAt = (from: number, step: number): boolean => {
2719
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2720
+ if (strong[k] !== null) return strong[k]!;
2721
+ }
2722
+ return rtl;
2723
+ };
2724
+ return strong.map((r, k) => {
2725
+ const reads =
2726
+ r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
2727
+ return reads ? 1 : rtl ? 2 : 0;
2728
+ });
2729
+ }
2730
+
2731
+ /**
2732
+ * A paragraph's UAX #9 levels, for its lines made a piece at a time. The
2733
+ * engine resolves bidi over what it is handed, which on such a line is a
2734
+ * piece: an embedding or an override opened on one side of an inline box's
2735
+ * edge or an atomic and closed on the other was resolved on each side
2736
+ * apart, and a neutral at a piece's edge took the paragraph's direction
2737
+ * wherever it sat (#149). So they are resolved here, once, over the
2738
+ * paragraph's text as its layouts see it — the controls `unicode-bidi`
2739
+ * stands for among it, and an object replacement character for each
2740
+ * atomic, as CSS Writing Modes 3 has one taken (2.4.2) — by bidi-js, which
2741
+ * is what ntk's own layout resolves them with.
2742
+ */
2743
+ interface ParagraphBidi {
2744
+ text: string;
2745
+ levels: Uint8Array;
2746
+ /** Whether it holds an explicit embedding, override or isolate, which
2747
+ * a piece laid out alone may be outside of. */
2748
+ explicit: boolean;
2749
+ /** The paragraph's own level: 1 where it reads right to left. */
2750
+ base: number;
2751
+ /** Where each item's text starts in `text`, by the item's index. */
2752
+ starts: number[];
2753
+ }
2754
+
2755
+ /** What a line's pieces are ordered by (`levelPieces`): the paragraph's
2756
+ * levels, and the fonts, font and line height a piece is laid out again
2757
+ * in where it is split. */
2758
+ interface LineLevels {
2759
+ bidi: ParagraphBidi;
2760
+ fonts: FontsLike;
2761
+ base: Record<string, unknown>;
2762
+ lineHeight: number;
2763
+ }
2764
+
2765
+ type TextPlaced = Extract<Placed, { kind: 'text' }>;
2766
+
2767
+ let BIDI: Bidi | null = null;
2768
+
2769
+ /** bidi-js, made the first time a paragraph needs it. Its declarations
2770
+ * give a CommonJS module a default export, which NodeNext reads as the
2771
+ * module object; Node and a bundler both hand over its factory. */
2772
+ function bidiJs(): Bidi {
2773
+ if (!BIDI) {
2774
+ const factory = bidiModule as unknown as
2775
+ (() => Bidi) | { default: () => Bidi };
2776
+ BIDI = (typeof factory === 'function' ? factory : factory.default)();
2777
+ }
2778
+ return BIDI;
2779
+ }
2780
+
2781
+ /** A paragraph's levels, or null where nothing in it can be reordered:
2782
+ * it reads left to right, and holds no right-to-left letter and no bidi
2783
+ * control. Kept on its block, and resolved again only where its text is
2784
+ * not what it was: a pass at another width has the same. */
2785
+ function paragraphBidi(
2786
+ block: Box,
2787
+ items: Item[],
2788
+ rtl: boolean,
2789
+ ): ParagraphBidi | null {
2790
+ let reorders = rtl;
2791
+ for (let i = 0; !reorders && i < items.length; i += 1) {
2792
+ const item = items[i];
2793
+ if (item.kind === 'text' && REORDERS.test(item.run.text)) reorders = true;
2794
+ }
2795
+ if (!reorders) return null;
2796
+ const starts: number[] = [];
2797
+ let text = '';
2798
+ for (const item of items) {
2799
+ starts.push(text.length);
2800
+ if (item.kind === 'text') text += item.run.text;
2801
+ else if (item.kind === 'atomic') text += '\ufffc';
2802
+ }
2803
+ const base = rtl ? 1 : 0;
2804
+ const kept = PARAGRAPH_BIDI.get(block);
2805
+ if (kept && kept.text === text && kept.base === base) {
2806
+ return { ...kept, starts };
2807
+ }
2808
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2809
+ const explicit = EXPLICIT.test(text);
2810
+ const bidi = { text, levels, explicit, base, starts };
2811
+ PARAGRAPH_BIDI.set(block, bidi);
2812
+ return bidi;
2813
+ }
2814
+
2815
+ const PARAGRAPH_BIDI = new WeakMap<Box, ParagraphBidi>();
2816
+
2817
+ /** The explicit embeddings, overrides and isolates. */
2818
+ const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
2819
+
2820
+ /** The explicit embeddings, overrides and isolates a text ends on. */
2821
+ const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
2822
+
2823
+ /** Whether a character is one X9 removes, or an isolate: a bidi control,
2824
+ * or a boundary neutral such as a zero-width space or a soft hyphen. */
2825
+ function isControl(c: number): boolean {
2826
+ return (
2827
+ (c >= 0x202a && c <= 0x202e) ||
2828
+ (c >= 0x2066 && c <= 0x2069) ||
2829
+ (c >= 0x200b && c <= 0x200d) ||
2830
+ (c >= 0x2060 && c <= 0x2064) ||
2831
+ c === 0xad ||
2832
+ c === 0xfeff ||
2833
+ c === 0x180e
2834
+ );
2835
+ }
2836
+
2837
+ /** The paired brackets UAX #9 resolves together (N0), which may pair
2838
+ * across a piece's end. */
2839
+ const BRACKETS =
2840
+ /[()[\]{}\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]/;
2841
+
2842
+ /** The strong types, which nothing around them resolves again. */
2843
+ const STRONG = new Set(['L', 'R', 'AL']);
2844
+
2845
+ /** What UAX #9 L1 puts back at the paragraph's level where a line ends on
2846
+ * it: white space, the isolates, and what X9 removed. */
2847
+ const TRAILING = new Set([
2848
+ 'WS',
2849
+ 'S',
2850
+ 'B',
2851
+ 'LRI',
2852
+ 'RLI',
2853
+ 'FSI',
2854
+ 'PDI',
2855
+ 'BN',
2856
+ 'LRE',
2857
+ 'RLE',
2858
+ 'LRO',
2859
+ 'RLO',
2860
+ 'PDF',
2861
+ ]);
2862
+
2863
+ /** A segment's text, and its levels as the engine resolves them, laid out
2864
+ * alone in the paragraph's direction. */
2865
+ const ALONE = new WeakMap<TextRun[], { text: string; levels: Uint8Array }>();
2866
+
2867
+ function aloneLevels(
2868
+ runs: TextRun[],
2869
+ rtl: boolean,
2870
+ ): { text: string; levels: Uint8Array } {
2871
+ let alone = ALONE.get(runs);
2872
+ if (!alone) {
2873
+ const text = runs.map((run) => run.text).join('');
2874
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2875
+ alone = { text, levels };
2876
+ ALONE.set(runs, alone);
2877
+ }
2878
+ return alone;
2879
+ }
2880
+
2881
+ /** Whether the text from a segment on, laid out alone, is ordered as the
2882
+ * paragraph orders it: every letter at the level it has there. */
2883
+ function orderedAlone(
2884
+ bidi: ParagraphBidi,
2885
+ segment: Segment,
2886
+ para: number,
2887
+ rtl: boolean,
2888
+ ): boolean {
2889
+ const { text, levels } = aloneLevels(segment.runs, rtl);
2890
+ for (let at = 0; at < levels.length; at += 1) {
2891
+ if (segment.spans.isGap(at)) continue;
2892
+ if (levels[at] === bidi.levels[para + at]) continue;
2893
+ if (!isControl(text.charCodeAt(at))) return false;
2894
+ }
2895
+ return true;
2896
+ }
2897
+
2898
+ /**
2899
+ * Give each piece on a line its level, and answer whether the line is to be
2900
+ * put in visual order at all. A piece's letters have the paragraph's levels,
2901
+ * with the white space the line ends on back at the paragraph's (L1). A
2902
+ * fragment was laid out alone and ordered inside itself by the engine: it
2903
+ * is kept whole where the engine's levels for it are the paragraph's and it
2904
+ * begins and ends at its lowest one, so that nothing of another piece comes
2905
+ * between its letters (L2), and it is the line's at that level. It is split
2906
+ * otherwise, a layout to each run of its letters at one level, held in that
2907
+ * level's direction by an override; the controls in it, which take no
2908
+ * room, are left out. An atomic is at its replacement character's level.
2909
+ */
2910
+ function levelPieces(open: OpenLine, line: LineLevels): boolean {
2911
+ const { bidi } = line;
2912
+ let start = Infinity;
2913
+ let end = -Infinity;
2914
+ for (const p of open.order) {
2915
+ if (p.kind === 'text' && p.from) {
2916
+ const natural = p.item.layout.lines[p.item.layoutLine];
2917
+ if (!natural) continue;
2918
+ start = Math.min(start, p.from.para + natural.start);
2919
+ end = Math.max(end, p.from.para + natural.end);
2920
+ } else if (p.kind === 'atomic' && p.para !== undefined) {
2921
+ start = Math.min(start, p.para);
2922
+ end = Math.max(end, p.para + 1);
2923
+ }
2924
+ }
2925
+ // an element's edges alone are ordered as the paragraph reads
2926
+ if (!(end > start)) return bidi.base > 0;
2927
+ const levels = bidi.levels.slice(start, end);
2928
+ const js = bidiJs();
2929
+ for (let i = end - 1; i >= start; i -= 1) {
2930
+ if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i]))) break;
2931
+ levels[i - start] = bidi.base;
2932
+ }
2933
+ const levelAt = (at: number): number => levels[at - start];
2934
+ let off = false;
2935
+ const order: Placed[] = [];
2936
+ const texts: LineText[] = [];
2937
+ for (const p of open.order) {
2938
+ if (p.kind === 'edge') {
2939
+ order.push(p);
2940
+ continue;
2941
+ }
2942
+ if (p.kind === 'atomic') {
2943
+ p.level = p.para === undefined ? bidi.base : levelAt(p.para);
2944
+ off ||= p.level > 0;
2945
+ order.push(p);
2946
+ continue;
2947
+ }
2948
+ for (const piece of levelled(p, levelAt, line)) {
2949
+ off ||= piece.level! > 0;
2950
+ order.push(piece);
2951
+ texts.push(piece.item);
2952
+ }
2953
+ }
2954
+ open.order = order;
2955
+ open.texts = texts;
2956
+ return off;
2957
+ }
2958
+
2959
+ /** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
2960
+ function levelled(
2961
+ p: TextPlaced,
2962
+ levelAt: (at: number) => number,
2963
+ line: LineLevels,
2964
+ ): TextPlaced[] {
2965
+ const text = p.item;
2966
+ const natural = text.layout.lines[text.layoutLine];
2967
+ const base = line.bidi.base;
2968
+ if (!p.from || !natural) return [{ ...p, level: base }];
2969
+ const { para, runs, spans } = p.from;
2970
+ const paragraph = line.bidi.text;
2971
+ // A bidi control takes no room and has no level of its own, whether
2972
+ // `unicode-bidi` stands for it or the document holds it: X9 removes it,
2973
+ // and bidi-js gives it the level of what is before it, which may be
2974
+ // another piece's. It is no end of a piece, nor of a run of one level.
2975
+ const quiet = (at: number): boolean =>
2976
+ spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
2977
+ let lowest = Infinity;
2978
+ let first = -1;
2979
+ let last = -1;
2980
+ for (let at = natural.start; at < natural.end; at += 1) {
2981
+ if (quiet(at)) continue;
2982
+ if (first < 0) first = at;
2983
+ last = at;
2984
+ lowest = Math.min(lowest, levelAt(para + at));
2985
+ }
2986
+ if (first < 0) return [{ ...p, level: base }];
2987
+ if (
2988
+ levelAt(para + first) === lowest &&
2989
+ levelAt(para + last) === lowest &&
2990
+ (plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
2991
+ laidAsLevelled(p.from, natural, levelAt, base))
2992
+ ) {
2993
+ return [{ ...p, level: lowest }];
2994
+ }
2995
+ const starts = runStarts(runs);
2996
+ const out: TextPlaced[] = [];
2997
+ let at = p.at;
2998
+ let lastRuns: TextRun[] = [];
2999
+ /** Where each piece is in the fragment's segment. */
3000
+ const ranges: [number, number][] = [];
3001
+ const piece = (from: number, to: number, level: number): void => {
3002
+ // its letters, each run's with where it is in the document and whose
3003
+ // text it is
3004
+ const letters: { run: TextRun; doc: number; box: Box | null }[] = [];
3005
+ let points = 0;
3006
+ for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
3007
+ const runStart = starts[k];
3008
+ if (runStart >= to) break;
3009
+ const a = Math.max(from, runStart);
3010
+ const b = Math.min(to, runStart + runs[k].text.length);
3011
+ if (b <= a || spans.isGap(a)) continue;
3012
+ const slice = runs[k].text.slice(a - runStart, b - runStart);
3013
+ letters.push({
3014
+ run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
3015
+ doc: spans.documentAt(a),
3016
+ box: spans.boxAt(a),
3017
+ });
3018
+ points += codeUnitOffsets(slice).length - 1;
3019
+ }
3020
+ if (!letters.length) return;
3021
+ const head = letters[0];
3022
+ const tail = letters[letters.length - 1];
3023
+ const docStart = head.doc;
3024
+ const docEnd = tail.doc + tail.run.text.length;
3025
+ const face = (run: TextRun, control: string): TextRun => ({
3026
+ text: control,
3027
+ family: run.family,
3028
+ size: run.size,
3029
+ weight: run.weight,
3030
+ style: run.style,
3031
+ });
3032
+ const rtl = (level & 1) === 1;
3033
+ const laidRuns: TextRun[] = [face(head.run, rtl ? '\u202e' : '\u202d')];
3034
+ const laidSpans = new SpanMap();
3035
+ laidSpans.add(docStart, 1, head.box, true);
3036
+ for (const { run, doc, box } of letters) {
3037
+ laidSpans.add(doc, run.text.length, box);
3038
+ laidRuns.push(run);
3039
+ }
3040
+ laidRuns.push(face(tail.run, '\u202c'));
3041
+ laidSpans.add(docEnd, 1, tail.box, true);
3042
+ const layout = line.fonts.layout(laidRuns, line.base, {
3043
+ lineHeight: line.lineHeight,
3044
+ align: 'left',
3045
+ direction: rtl ? 'rtl' : 'ltr',
3046
+ fit: PARAGRAPH_FIT,
3047
+ });
3048
+ LAYOUT_RUNS.set(layout, laidRuns);
3049
+ lastRuns = laidRuns;
3050
+ const laid = layout.lines[0];
3051
+ const item: LineText = {
3052
+ layout,
3053
+ layoutLine: 0,
3054
+ drawX: text.drawX + (at - p.at),
3055
+ drawY: 0,
3056
+ textStart: docStart,
3057
+ textEnd: docEnd,
3058
+ layoutStart: laid ? laid.start : 0,
3059
+ spans: laidSpans,
3060
+ };
3061
+ laidSpans.giveGaps(item, 0, laidSpans.laidOut);
3062
+ out.push({ kind: 'text', at, item, reads: null, level });
3063
+ ranges.push([from, to]);
3064
+ // the room it takes, its white space at its end with it, which the
3065
+ // engine hangs past the layout's width: from caret to caret
3066
+ const extent = Math.abs(
3067
+ layout.caretPosition(1 + points).x - layout.caretPosition(1).x,
3068
+ );
3069
+ at += Math.max(laid?.width ?? 0, extent);
3070
+ };
3071
+ let from = first;
3072
+ let level = levelAt(para + first);
3073
+ let prev = first;
3074
+ for (let o = first + 1; o <= last; o += 1) {
3075
+ if (quiet(o)) continue;
3076
+ const l = levelAt(para + o);
3077
+ if (l !== level) {
3078
+ piece(from, prev + 1, level);
3079
+ from = o;
3080
+ level = l;
3081
+ }
3082
+ prev = o;
3083
+ }
3084
+ piece(from, last + 1, level);
3085
+ if (!out.length) return [{ ...p, level: base }];
3086
+ // a word across two of the pieces runs on from one to the next, with no
3087
+ // edge between, as the fragment ran on from the text before it (`JOINS`)
3088
+ const joins = JOINS.get(text);
3089
+ if (joins !== undefined) JOINS.set(out[0].item, joins);
3090
+ for (let k = 1; k < out.length; k += 1) {
3091
+ const before = paragraph[para + ranges[k - 1][1] - 1];
3092
+ const after = paragraph[para + ranges[k][0]];
3093
+ if (!SPACE.test(before) && !SPACE.test(after)) JOINS.set(out[k].item, 0);
3094
+ }
3095
+ // the spaces `pre-wrap` keeps that the fragment ended on are its last
3096
+ // piece's, which the engine hangs as it hung them (`hangPreserved`)
3097
+ const tail = out[out.length - 1];
3098
+ if (text.hung) {
3099
+ const rtl = (tail.level! & 1) === 1;
3100
+ hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
3101
+ }
3102
+ return out;
3103
+ }
3104
+
3105
+ /**
3106
+ * Whether the engine, laying a fragment out alone, must have given its
3107
+ * letters the paragraph's levels, without resolving them to see: in a
3108
+ * paragraph of no explicit embedding, a stretch that begins and ends with a
3109
+ * strong letter and pairs no bracket is resolved inside itself, whatever is
3110
+ * around it (W1 to W7, N1 and N2 look no further than the strong letters
3111
+ * either side). The white space it ends on is the paragraph's level to the
3112
+ * engine, whose line ends there (L1), and has to be to the line.
3113
+ */
3114
+ function plainlyLevelled(
3115
+ bidi: ParagraphBidi,
3116
+ from: NonNullable<TextPlaced['from']>,
3117
+ first: number,
3118
+ last: number,
3119
+ levelAt: (at: number) => number,
3120
+ ): boolean {
3121
+ if (bidi.explicit) return false;
3122
+ const text = bidi.text;
3123
+ const js = bidiJs();
3124
+ let end = last;
3125
+ while (
3126
+ end > first &&
3127
+ TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))
3128
+ ) {
3129
+ if (levelAt(from.para + end) !== bidi.base) return false;
3130
+ end -= 1;
3131
+ }
3132
+ return (
3133
+ STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
3134
+ STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
3135
+ !BRACKETS.test(text.slice(from.para + first, from.para + end + 1))
3136
+ );
3137
+ }
3138
+
3139
+ /** Whether the engine, laying a fragment out alone, gave its letters the
3140
+ * levels the paragraph gives them: with its line's end white space back
3141
+ * at the paragraph's level (L1), as the engine's own line ends there. */
3142
+ function laidAsLevelled(
3143
+ from: NonNullable<TextPlaced['from']>,
3144
+ natural: TextLayoutLike['lines'][number],
3145
+ levelAt: (at: number) => number,
3146
+ base: number,
3147
+ ): boolean {
3148
+ const { text, levels } = aloneLevels(from.runs, base === 1);
3149
+ const js = bidiJs();
3150
+ const quiet = (at: number): boolean =>
3151
+ from.spans.isGap(at) || isControl(text.charCodeAt(at));
3152
+ let hung = natural.end;
3153
+ while (
3154
+ hung > natural.start &&
3155
+ TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))
3156
+ ) {
3157
+ hung -= 1;
3158
+ }
3159
+ for (let at = natural.start; at < natural.end; at += 1) {
3160
+ if (quiet(at)) continue;
3161
+ const alone = at >= hung ? base : levels[at];
3162
+ if (alone !== levelAt(from.para + at)) return false;
3163
+ }
3164
+ return true;
3165
+ }
3166
+
2097
3167
  /** Whether a piece can be out of place in logical order: anything on a
2098
3168
  * right-to-left line, a fragment that reads right to left, or the edge of
2099
3169
  * an element whose direction is not the line's. A line of left-to-right
@@ -2221,7 +3291,14 @@ function atomicBaseline(box: Box): number {
2221
3291
  const bottom = box.height + box.marginTop + box.marginBottom;
2222
3292
  if (
2223
3293
  box.kind === 'replaced' ||
2224
- scrolls(box.style) ||
3294
+ // a box that clips sits on its bottom margin edge, for legacy reasons
3295
+ // that stop at block containers (CSS Box Alignment 3, 9.2) — and an
3296
+ // inline table, as browsers keep it: an inline flex box that clips
3297
+ // sits on its first item's, as it does unclipped. A MediaWiki button
3298
+ // is an `overflow: hidden` inline flex box of one icon, and stood its
3299
+ // whole height on the baseline, the line under it the strut's descent
3300
+ // taller
3301
+ (scrolls(box.style) && box.kind !== 'flex') ||
2225
3302
  // layout containment keeps its baseline in (CSS Containment 2, 3.3)
2226
3303
  box.style.contain & CONTAIN_LAYOUT
2227
3304
  ) {
@@ -2356,7 +3433,13 @@ function childBaseline(
2356
3433
  if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
2357
3434
  return null;
2358
3435
  }
2359
- if (scrolls(child.style)) {
3436
+ // the legacy rule is a block container's last baseline alone (CSS Box
3437
+ // Alignment 3, 9.2): its first is its first line's, clipped or not
3438
+ if (
3439
+ inside === lastBaselineIn &&
3440
+ child.kind !== 'flex' &&
3441
+ scrolls(child.style)
3442
+ ) {
2360
3443
  return child.y + child.height + child.marginBottom;
2361
3444
  }
2362
3445
  return inside(child);
@@ -2781,7 +3864,12 @@ function wordSpaced(
2781
3864
  run: {
2782
3865
  ...run,
2783
3866
  text: text.slice(from, to),
2784
- ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
3867
+ ...(extra
3868
+ ? {
3869
+ letterSpacing: (run.letterSpacing ?? 0) + extra,
3870
+ kernAcross: true,
3871
+ }
3872
+ : null),
2785
3873
  },
2786
3874
  box,
2787
3875
  length: to - from,
@@ -2832,6 +3920,9 @@ function inlineEdges(
2832
3920
  }
2833
3921
  }
2834
3922
  box.decoration = inlineDecoration(fonts, box, s);
3923
+ if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
3924
+ PADDED_FACES.set(box, faceExtent(fonts, s));
3925
+ } else PADDED_FACES.delete(box);
2835
3926
  const left = box.marginLeft + box.borderLeft + box.padLeft;
2836
3927
  const right = box.padRight + box.borderRight + box.marginRight;
2837
3928
  return s.direction === 'rtl' ? [right, left] : [left, right];
@@ -2848,8 +3939,13 @@ export function inlineDecoration(
2848
3939
  box: Box,
2849
3940
  s: ComputedStyle,
2850
3941
  ): InlineDecoration | null {
3942
+ // a background is a colour, a gradient or an image: a span a gradient
3943
+ // underlines, a highlighter's, was painted only where it had a border
2851
3944
  const decorated =
2852
3945
  !isTransparent(s.backgroundColor) ||
3946
+ s.backgroundGradient !== null ||
3947
+ s.backgroundImage !== null ||
3948
+ s.backgroundImages !== null ||
2853
3949
  box.borderTop + box.borderRight + box.borderBottom + box.borderLeft > 0 ||
2854
3950
  s.outlineStyle !== 'none';
2855
3951
  return !decorated ? null : fonts ? faceExtent(fonts, s) : NO_EXTENT;
@@ -2952,9 +4048,26 @@ function firstLineSetting(
2952
4048
  box === block || !box.parent
2953
4049
  ? style
2954
4050
  : styleOnFirstLine(box, of(box.parent), how.styler);
4051
+ // and in its `text-transform` where the line has one of its own, which
4052
+ // the boxes' text was not made with: a word carried from one run into
4053
+ // the next, as the box builder carries it (`letteredAfter`)
4054
+ let lettered = false;
2955
4055
  const restyled = items.map((item): Item => {
4056
+ if (item.kind === 'atomic') lettered = false;
2956
4057
  if (item.kind !== 'text') return item;
2957
- const run = restyledRun(item.run, item.box.style, of(item.box));
4058
+ const own = item.box.style;
4059
+ const on = of(item.box);
4060
+ let run = restyledRun(item.run, own, on);
4061
+ const transform = on.textTransform;
4062
+ if (
4063
+ !item.control &&
4064
+ transform !== own.textTransform &&
4065
+ transform !== 'none'
4066
+ ) {
4067
+ const text = transformInPlace(run.text, transform, lettered);
4068
+ if (text !== run.text) run = { ...run, text };
4069
+ }
4070
+ lettered = letteredAfter(run.text, lettered);
2958
4071
  return run === item.run ? item : { ...item, run };
2959
4072
  });
2960
4073
  const lineHeight = lineHeightMultiplier(fonts, style);
@@ -3307,6 +4420,199 @@ function heldText(box: Box): string {
3307
4420
 
3308
4421
  const HELD = new WeakMap<Box, string>();
3309
4422
 
4423
+ /**
4424
+ * Whether a line one layout made is shorter about its baseline than the
4425
+ * block's strut. Every line box starts with the strut, the block's font at
4426
+ * its line height (CSS 2.1 10.8.1), and a layout has none: it makes a line
4427
+ * as tall as the text on it, at the block's line height as a multiple of
4428
+ * each face's natural one. So a line of `<small>` text alone came out as
4429
+ * short as the small text, and overlapped what followed. A paragraph with
4430
+ * such a line is made a line at a time instead, where `finishLine` puts
4431
+ * the strut on every line; one whose lines all hold text of the block's
4432
+ * own face is kept as it was laid out.
4433
+ */
4434
+ function shortOfStrut(
4435
+ lines: readonly LineBox[],
4436
+ strut: InlineDecoration | null,
4437
+ /** The first line's, where it holds more: a marker's room. */
4438
+ first: InlineDecoration | null = null,
4439
+ ): boolean {
4440
+ for (let i = 0; i < lines.length; i += 1) {
4441
+ const line = lines[i];
4442
+ const holds = i === 0 && first ? first : strut;
4443
+ if (!holds) continue;
4444
+ // its room as the strut's is worked out, whichever baseline the layout
4445
+ // it is drawn with could keep (`layoutLift`)
4446
+ const text = line.texts.length === 1 ? line.texts[0] : null;
4447
+ const natural = text?.layout.lines[text.layoutLine];
4448
+ const ascent = natural ? lineAscent(natural) : line.baseline;
4449
+ if (
4450
+ ascent < holds.ascent - 0.5 ||
4451
+ line.height - ascent < holds.descent - 0.5
4452
+ ) {
4453
+ return true;
4454
+ }
4455
+ }
4456
+ return false;
4457
+ }
4458
+
4459
+ /** A strut with room besides it about the same baseline, or the strut
4460
+ * where there is none. */
4461
+ function withRoom(
4462
+ strut: InlineDecoration | null,
4463
+ room: InlineDecoration | undefined,
4464
+ ): InlineDecoration | null {
4465
+ if (!room) return strut;
4466
+ if (!strut) return room;
4467
+ return {
4468
+ ascent: Math.max(strut.ascent, room.ascent),
4469
+ descent: Math.max(strut.descent, room.descent),
4470
+ };
4471
+ }
4472
+
4473
+ /** The share of the size a synthesized small capital is set at, rounded to
4474
+ * a whole pixel as Blink rounds it (`SimpleFontData`'s scaled font data):
4475
+ * CSS Fonts 4 (6.2) leaves the size to the user agent. */
4476
+ const SMALL_CAPS_SCALE = 0.7;
4477
+
4478
+ /**
4479
+ * Small capitals a face does not have, made of its capitals set smaller
4480
+ * (CSS Fonts 4, 6.2): where `font-variant-caps` asks for `smcp` — or
4481
+ * `c2sc` too, for `all-small-caps` — and the face does not answer it,
4482
+ * each letter that would be a small capital is its capital at 70% of the
4483
+ * size, in a run of its own: a lower-case letter, and for `all-small-caps`
4484
+ * the rest of the text too. Under `small-caps` what has no case — a space,
4485
+ * a digit, a mark of punctuation — keeps its size, as Blink keeps it. Only
4486
+ * a letter whose capital is one character is made one, so every offset
4487
+ * holds.
4488
+ */
4489
+ function synthesizeSmallCaps(items: Item[], fonts: FontsLike): void {
4490
+ for (let i = 0; i < items.length; i++) {
4491
+ const item = items[i];
4492
+ if (item.kind !== 'text' || item.control) continue;
4493
+ // `font-variant-caps`'s, which is synthesized where the face has none;
4494
+ // not `font-feature-settings`', which asks for the feature as it is
4495
+ const caps = item.box.style.fontVariantCaps;
4496
+ const features = item.run.features;
4497
+ if (!caps.includes('smcp') || !features?.smcp) continue;
4498
+ const all = caps.includes('c2sc');
4499
+ if (hasSmallCaps(fonts, item.run, all)) continue;
4500
+ const pieces = smallCapsPieces(item, all, withoutCaps(features));
4501
+ if (!pieces) continue;
4502
+ items.splice(i, 1, ...pieces);
4503
+ i += pieces.length - 1;
4504
+ }
4505
+ }
4506
+
4507
+ /** A text item cut where its letters turn to synthesized small capitals
4508
+ * and back, or null where none does. */
4509
+ function smallCapsPieces(
4510
+ item: Extract<Item, { kind: 'text' }>,
4511
+ all: boolean,
4512
+ features: Readonly<Record<string, number>> | null,
4513
+ ): Extract<Item, { kind: 'text' }>[] | null {
4514
+ const text = item.run.text;
4515
+ const pieces: Extract<Item, { kind: 'text' }>[] = [];
4516
+ let from = 0;
4517
+ let small = false;
4518
+ let capitals = '';
4519
+ const cut = (to: number): void => {
4520
+ if (to === from) return;
4521
+ const run: TextRun = small
4522
+ ? {
4523
+ ...item.run,
4524
+ text: capitals,
4525
+ size: Math.round((item.run.size ?? 16) * SMALL_CAPS_SCALE),
4526
+ }
4527
+ : { ...item.run, text: text.slice(from, to) };
4528
+ if (small) {
4529
+ if (features) run.features = features;
4530
+ else delete run.features;
4531
+ }
4532
+ pieces.push({ ...item, run, start: item.start + from, length: to - from });
4533
+ from = to;
4534
+ capitals = '';
4535
+ };
4536
+ let any = false;
4537
+ for (let at = 0; at < text.length;) {
4538
+ const ch = String.fromCodePoint(text.codePointAt(at)!);
4539
+ const upper = ch.toUpperCase();
4540
+ // everything, for `all-small-caps`: its spaces are small too
4541
+ const becomes = (all || upper !== ch) && upper.length === ch.length;
4542
+ if (becomes !== small) {
4543
+ cut(at);
4544
+ small = becomes;
4545
+ }
4546
+ if (becomes) {
4547
+ capitals += upper;
4548
+ any = true;
4549
+ }
4550
+ at += ch.length;
4551
+ }
4552
+ cut(text.length);
4553
+ return any ? pieces : null;
4554
+ }
4555
+
4556
+ /** Features without the small capitals a synthesis stands in for, one
4557
+ * object for each set, as `featuresOf` keeps them. */
4558
+ function withoutCaps(
4559
+ features: Readonly<Record<string, number>>,
4560
+ ): Readonly<Record<string, number>> | null {
4561
+ let kept = WITHOUT_CAPS.get(features);
4562
+ if (kept === undefined) {
4563
+ const out: Record<string, number> = {};
4564
+ for (const [tag, value] of Object.entries(features)) {
4565
+ if (tag !== 'smcp' && tag !== 'c2sc') out[tag] = value;
4566
+ }
4567
+ kept = Object.keys(out).length ? out : null;
4568
+ WITHOUT_CAPS.set(features, kept);
4569
+ }
4570
+ return kept;
4571
+ }
4572
+
4573
+ const WITHOUT_CAPS = new WeakMap<
4574
+ Readonly<Record<string, number>>,
4575
+ Readonly<Record<string, number>> | null
4576
+ >();
4577
+
4578
+ /**
4579
+ * Whether a run's face has small capitals of its own — and capitals to
4580
+ * small capitals, for `all-small-caps`: the alphabet set with the feature
4581
+ * and without it comes out another width where it does. Asked of the
4582
+ * engine's layout rather than of the font's tables, which a CoreText face
4583
+ * does not hand over. Kept per face.
4584
+ */
4585
+ function hasSmallCaps(fonts: FontsLike, run: TextRun, all: boolean): boolean {
4586
+ let known = SMALL_CAPS.get(fonts);
4587
+ if (!known) SMALL_CAPS.set(fonts, (known = new Map()));
4588
+ const key = `${run.family}|${run.weight}|${run.style}|${all}`;
4589
+ let has = known.get(key);
4590
+ if (has === undefined) {
4591
+ const face = {
4592
+ family: run.family,
4593
+ weight: run.weight,
4594
+ style: run.style,
4595
+ size: 16,
4596
+ };
4597
+ const width = (text: string, features: Record<string, number> | null) =>
4598
+ fonts.layout(
4599
+ [{ text, ...face, ...(features ? { features } : null) }],
4600
+ face,
4601
+ {},
4602
+ ).width;
4603
+ const lower = 'abcdefghijklmnopqrstuvwxyz';
4604
+ has = Math.abs(width(lower, { smcp: 1 }) - width(lower, null)) > 0.01;
4605
+ if (has && all) {
4606
+ const upper = lower.toUpperCase();
4607
+ has = Math.abs(width(upper, { c2sc: 1 }) - width(upper, null)) > 0.01;
4608
+ }
4609
+ known.set(key, has);
4610
+ }
4611
+ return has;
4612
+ }
4613
+
4614
+ const SMALL_CAPS = new WeakMap<FontsLike, Map<string, boolean>>();
4615
+
3310
4616
  /**
3311
4617
  * The OpenType features a style's text is shaped with: the `font-variant`
3312
4618
  * longhands' and `font-kerning`'s, then `font-feature-settings`, which has
@@ -3428,11 +4734,19 @@ class SpanMap {
3428
4734
  private _gaps: number[] | null = null;
3429
4735
  laidOut = 0;
3430
4736
 
3431
- add(documentStart: number, length: number, box: Box | null = null): void {
4737
+ add(
4738
+ documentStart: number,
4739
+ length: number,
4740
+ box: Box | null = null,
4741
+ /** No text of the document's, though a box's: a bidi control a
4742
+ * level's layout is wrapped in (`levelPieces`), which answers for
4743
+ * the element around it. */
4744
+ gap = box === null,
4745
+ ): void {
3432
4746
  this._laid.push(this.laidOut);
3433
4747
  this._doc.push(documentStart);
3434
4748
  this._boxes.push(box);
3435
- if (!box) {
4749
+ if (gap) {
3436
4750
  this._gaps ??= [];
3437
4751
  for (let i = 0; i < length; i += 1) this._gaps.push(this.laidOut + i);
3438
4752
  }
@@ -3448,6 +4762,21 @@ class SpanMap {
3448
4762
  if (gaps.length) text.gaps = gaps;
3449
4763
  }
3450
4764
 
4765
+ /** Whether an offset in the laid-out text is no text of the
4766
+ * document's: a spacer, a bidi control. */
4767
+ isGap(offset: number): boolean {
4768
+ const gaps = this._gaps;
4769
+ if (!gaps) return false;
4770
+ let lo = 0;
4771
+ let hi = gaps.length;
4772
+ while (lo < hi) {
4773
+ const mid = (lo + hi) >> 1;
4774
+ if (gaps[mid] < offset) lo = mid + 1;
4775
+ else hi = mid;
4776
+ }
4777
+ return gaps[lo] === offset;
4778
+ }
4779
+
3451
4780
  /** The text box whose text an offset in the laid-out text is. */
3452
4781
  boxAt(offset: number): Box | null {
3453
4782
  const i = this._entryAt(offset);
@@ -3593,6 +4922,183 @@ export function hungSpaces(fonts: FontsLike, run: TextRun): RegExp {
3593
4922
  return hung;
3594
4923
  }
3595
4924
 
4925
+ /** Where each run starts in their joined text. */
4926
+ function runStarts(runs: TextRun[]): number[] {
4927
+ const starts: number[] = [];
4928
+ let at = 0;
4929
+ for (const run of runs) {
4930
+ starts.push(at);
4931
+ at += run.text.length;
4932
+ }
4933
+ return starts;
4934
+ }
4935
+
4936
+ /** The run a code-unit offset in the runs' joined text falls in. */
4937
+ function runIndexAt(starts: number[], offset: number): number {
4938
+ let lo = 0;
4939
+ let hi = starts.length - 1;
4940
+ while (lo < hi) {
4941
+ const mid = (lo + hi + 1) >> 1;
4942
+ if (starts[mid] <= offset) lo = mid;
4943
+ else hi = mid - 1;
4944
+ }
4945
+ return lo;
4946
+ }
4947
+
4948
+ /**
4949
+ * The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
4950
+ * logical order and each with its advance, and whether a forced break
4951
+ * follows them. An engine strips a line's trailing white space, which is
4952
+ * right for the collapsible spaces CSS removes there, and wrong for these.
4953
+ */
4954
+ function keptAtEnd(
4955
+ natural: TextLayoutLike['lines'][number],
4956
+ runs: TextRun[],
4957
+ starts: number[],
4958
+ spans: LineText['spans'],
4959
+ fonts: FontsLike,
4960
+ ): { found: HungSpace[] | null; forced: boolean } {
4961
+ const boxAt = spans.boxAt;
4962
+ if (!boxAt || !runs.length) return { found: null, forced: false };
4963
+ const charAt = (offset: number): number => {
4964
+ const k = runIndexAt(starts, offset);
4965
+ return runs[k].text.charCodeAt(offset - starts[k]);
4966
+ };
4967
+ let end = natural.end;
4968
+ let forced = false;
4969
+ if (end > natural.start && charAt(end - 1) === 10) {
4970
+ forced = true;
4971
+ end -= 1;
4972
+ }
4973
+ let found: HungSpace[] | null = null;
4974
+ let kept = false;
4975
+ for (let at = end - 1; at >= natural.start; at -= 1) {
4976
+ const c = charAt(at);
4977
+ if (isControl(c)) continue;
4978
+ if (c !== 0x20) break;
4979
+ // a collapsible space after the last kept one is removed (4.1.3), and
4980
+ // one before a kept one hangs with it
4981
+ const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
4982
+ if (!keeps && !kept) continue;
4983
+ kept = true;
4984
+ const run = runs[runIndexAt(starts, at)];
4985
+ (found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
4986
+ }
4987
+ found?.reverse();
4988
+ return { found, forced };
4989
+ }
4990
+
4991
+ type HungSpace = NonNullable<LineText['hung']>[number];
4992
+
4993
+ /**
4994
+ * The spaces `pre-wrap` keeps that a text's line ends on, which the engine
4995
+ * hung (`keptAtEnd`): past the line's end, to its left where the layout
4996
+ * reads right to left, and their inline box's, whose background and border
4997
+ * cover them, as a browser draws them. Noted on the text (`LineText.hung`),
4998
+ * and their advance answered, with whether a forced break follows them.
4999
+ */
5000
+ function hangPreserved(
5001
+ text: LineText,
5002
+ runs: TextRun[],
5003
+ starts: number[],
5004
+ fonts: FontsLike,
5005
+ rtl: boolean,
5006
+ ): { total: number; forced: boolean } {
5007
+ const natural = text.layout.lines[text.layoutLine];
5008
+ if (!natural) return { total: 0, forced: false };
5009
+ const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
5010
+ if (!found) return { total: 0, forced };
5011
+ text.hung = found;
5012
+ placeHung(text, rtl);
5013
+ let total = 0;
5014
+ for (const space of found) total += space.width;
5015
+ return { total, forced };
5016
+ }
5017
+
5018
+ const NOTHING_HUNG = { total: 0, forced: false };
5019
+
5020
+ /** Put a text's hung spaces beside its line's end, logical order outward:
5021
+ * to the right of its glyphs, or to their left. */
5022
+ function placeHung(text: LineText, left: boolean): void {
5023
+ const natural = text.layout.lines[text.layoutLine];
5024
+ if (!text.hung || !natural) return;
5025
+ let x = left ? natural.x : natural.x + natural.width;
5026
+ for (const space of text.hung) {
5027
+ if (left) x -= space.width;
5028
+ space.x = x;
5029
+ if (!left) x += space.width;
5030
+ }
5031
+ }
5032
+
5033
+ /**
5034
+ * The runs of a layout with the spaces `pre-wrap` keeps before a forced
5035
+ * break held where they fit (`keptAtEnd`) — a kept newline, or the block's
5036
+ * end after the last line where `ends` — or null where none are. They hang
5037
+ * there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
5038
+ * which the engine gives them when they are handed over as no-break
5039
+ * spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
5040
+ * line with them. Laid out again, the lines break where they did, since
5041
+ * the spaces fit where they are. A line of a layout cannot be moved over
5042
+ * for them afterwards: its lines are drawn in one draw, from one place.
5043
+ */
5044
+ function holdAtBreaks(
5045
+ layout: TextLayoutLike,
5046
+ runs: TextRun[],
5047
+ spans: SpanMap,
5048
+ fonts: FontsLike,
5049
+ width: number,
5050
+ ends: boolean,
5051
+ ): TextRun[] | null {
5052
+ const starts = runStarts(runs);
5053
+ let held: number[] | null = null;
5054
+ const lines = layout.lines;
5055
+ for (let i = 0; i < lines.length; i += 1) {
5056
+ const natural = lines[i];
5057
+ const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
5058
+ if (!found || !(forced || (ends && i === lines.length - 1))) continue;
5059
+ let free = width - natural.width;
5060
+ for (const space of found) {
5061
+ if (space.width > free + 0.01) break;
5062
+ free -= space.width;
5063
+ (held ??= []).push(space.at);
5064
+ }
5065
+ }
5066
+ if (!held) return null;
5067
+ held.sort((a, b) => a - b);
5068
+ const out = runs.slice();
5069
+ let next = 0;
5070
+ for (let k = 0; k < runs.length && next < held.length; k += 1) {
5071
+ const start = starts[k];
5072
+ const end = start + runs[k].text.length;
5073
+ if (held[next] >= end) continue;
5074
+ const chars = runs[k].text.split('');
5075
+ while (next < held.length && held[next] < end) {
5076
+ chars[held[next] - start] = '\u00a0';
5077
+ next += 1;
5078
+ }
5079
+ out[k] = { ...runs[k], text: chars.join('') };
5080
+ }
5081
+ return out;
5082
+ }
5083
+
5084
+ /** The spaces `pre-wrap` keeps that the lines one layout made hung at
5085
+ * their ends (`hangPreserved`), noted on each line's text. */
5086
+ function hangLines(
5087
+ lines: LineBox[],
5088
+ from: number,
5089
+ runs: TextRun[],
5090
+ fonts: FontsLike,
5091
+ rtl: boolean,
5092
+ ): void {
5093
+ let starts: number[] | null = null;
5094
+ for (let i = from; i < lines.length; i += 1) {
5095
+ const text = lines[i].texts[0];
5096
+ if (!text) continue;
5097
+ starts ??= runStarts(runs);
5098
+ hangPreserved(text, runs, starts, fonts, rtl);
5099
+ }
5100
+ }
5101
+
3596
5102
  /**
3597
5103
  * A clamped paragraph with an ellipsis at the end of its last line (CSS
3598
5104
  * Overflow 4, 4.2): its text up to where that line breaks in the room the
@@ -3867,14 +5373,17 @@ function unwrappedPlacer(
3867
5373
 
3868
5374
  /**
3869
5375
  * The spacing a space is given to be laid out as one that may be spaced
3870
- * apart, too little to move a glyph. It is not nothing, because neither
3871
- * engine measures a space that is a spaced run of its own as it measures
3872
- * the same space inside its run: ntk shapes each run apart, so the kerning
3873
- * pair a space made with the letter beside it is gone (Arial's `A` and
3874
- * `T` have them), and CoreText spaces a glyph with its kerning attribute,
3875
- * which takes the place of the font's pairs. A line justified from a
3876
- * measure of its spaces as they were came out wider than its box and
3877
- * broke a word early: on macOS, in Helvetica, half of a paragraph did.
5376
+ * apart, too little to move a glyph. It is not nothing, because CoreText
5377
+ * does not measure a space that is a spaced run of its own as it measures
5378
+ * the same space inside its run: it spaces a glyph with its kerning
5379
+ * attribute, which takes the place of the font's pairs. A line justified
5380
+ * from a measure of its spaces as they were came out wider than its box
5381
+ * and broke a word early: on macOS, in Helvetica, half of a paragraph did.
5382
+ * ntk keeps the pairs a spaced space makes with the letters beside it
5383
+ * (Arial's space and `T`, its `L` and space) where the run is marked
5384
+ * `kernAcross` — spacing that justifies a line is in addition to kerning
5385
+ * (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
5386
+ * a browser's is.
3878
5387
  */
3879
5388
  const HAIR = 1e-6;
3880
5389
 
@@ -3892,6 +5401,7 @@ function spacedApart(runs: TextRun[]): TextRun[] {
3892
5401
  ...run,
3893
5402
  text: text[at],
3894
5403
  letterSpacing: (run.letterSpacing ?? 0) + HAIR,
5404
+ kernAcross: true,
3895
5405
  });
3896
5406
  piece = at + 1;
3897
5407
  }
@@ -3971,6 +5481,7 @@ function widenedAt(runs: TextRun[], extra: Map<number, number>): TextRun[] {
3971
5481
  ...run,
3972
5482
  text: text[at],
3973
5483
  letterSpacing: (run.letterSpacing ?? 0) + add,
5484
+ kernAcross: true,
3974
5485
  });
3975
5486
  piece = at + 1;
3976
5487
  }
@@ -4248,8 +5759,8 @@ function naturalLineHeight(fonts: FontsLike, style: ComputedStyle): number {
4248
5759
 
4249
5760
  /**
4250
5761
  * A block's strut: the ascent and descent of a line of nothing in its face,
4251
- * the half-leading its `line-height` adds shared above and below, as the
4252
- * text engines set a line (CSS 2.1 10.8.1).
5762
+ * the leading its `line-height` adds shared above and below as
5763
+ * `ascentOnLine` shares it (CSS 2.1 10.8.1).
4253
5764
  */
4254
5765
  export function strutOf(
4255
5766
  fonts: FontsLike,
@@ -4265,8 +5776,62 @@ export function strutOf(
4265
5776
  : style.lineHeightIsLength
4266
5777
  ? (style.lineHeight as number)
4267
5778
  : (style.lineHeight as number) * style.fontSize;
4268
- const half = (target - face.ascent - face.descent) / 2;
4269
- return { ascent: face.ascent + half, descent: face.descent + half };
5779
+ const ascent = ascentOnLine(face.ascent, face.descent, target);
5780
+ return { ascent, descent: target - ascent };
5781
+ }
5782
+
5783
+ /**
5784
+ * How far above its baseline an inline box's room on a line reaches: its
5785
+ * face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
5786
+ * rounded down to a whole pixel, the rest of the leading going below.
5787
+ *
5788
+ * The spec's halves are exact, and where one is not a whole pixel which
5789
+ * side of the grid the text lands on is the user agent's. Blink rounds the
5790
+ * half above down (`CalculateLeadingSpace`, core/layout/inline/
5791
+ * line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
5792
+ * 15 and 2px under. Split evenly, that box reached half a pixel above the
5793
+ * 12px strut beside it, whose 4px of leading divides, and every such line
5794
+ * came out 18.5px tall.
5795
+ */
5796
+ export function ascentOnLine(
5797
+ ascent: number,
5798
+ descent: number,
5799
+ height: number,
5800
+ ): number {
5801
+ return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
5802
+ }
5803
+
5804
+ /**
5805
+ * How far above its baseline a text engine's line reaches, its leading
5806
+ * shared as `ascentOnLine` shares it. The engines split it evenly (the
5807
+ * contract every text engine owes, a baseline `(height - ascent -
5808
+ * descent) / 2 + ascent` down), so a line on its own sits up to a pixel
5809
+ * lower than a browser's; one that did not say how tall its face is keeps
5810
+ * the engine's.
5811
+ */
5812
+ function lineAscent(natural: TextLayoutLike['lines'][number]): number {
5813
+ const { ascent, descent } = natural;
5814
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
5815
+ return natural.baseline - natural.y;
5816
+ }
5817
+ return ascentOnLine(ascent, descent, natural.height);
5818
+ }
5819
+
5820
+ /**
5821
+ * How far up a layout drawn whole moves for each of its lines' baselines
5822
+ * to be where `lineAscent` puts it. A layout is drawn in one batch, from
5823
+ * one origin, so it moves by one amount or not at all: where its lines
5824
+ * would move by different amounts — a face of other metrics on one — it
5825
+ * keeps the engine's baselines, which are less than a pixel out.
5826
+ */
5827
+ function layoutLift(layout: TextLayoutLike): number {
5828
+ let lift = NaN;
5829
+ for (const natural of layout.lines) {
5830
+ const own = natural.baseline - natural.y - lineAscent(natural);
5831
+ if (Number.isNaN(lift)) lift = own;
5832
+ else if (Math.abs(own - lift) > 1e-6) return 0;
5833
+ }
5834
+ return Number.isNaN(lift) ? 0 : lift;
4270
5835
  }
4271
5836
 
4272
5837
  /** Where a text `vertical-align` raised goes on its line. */
@@ -4471,13 +6036,19 @@ function ownsLeading(
4471
6036
  block: ComputedStyle,
4472
6037
  style: ComputedStyle,
4473
6038
  ): boolean {
4474
- // a bold or an italic face keeps its family's line metrics, and a
4475
- // paragraph is thick with <strong>, <em> and <a>
6039
+ // the block's own face is past at once. A bold or an italic one is not:
6040
+ // a paragraph is thick with <strong>, <em> and <a>, and most families'
6041
+ // faces share their line metrics, but not every one's — Helvetica Neue
6042
+ // Bold reaches 0.975em above its baseline to the regular's 0.952, and
6043
+ // Blink, which rounds each to a whole pixel, sets a 12px bold word on a
6044
+ // 19.2px line a pixel taller. It is measured once for its style, below.
4476
6045
  if (
4477
6046
  style.lineHeight === block.lineHeight &&
4478
6047
  style.lineHeightIsLength === block.lineHeightIsLength &&
4479
6048
  style.fontSize === block.fontSize &&
4480
- style.fontFamily === block.fontFamily
6049
+ style.fontFamily === block.fontFamily &&
6050
+ style.fontWeight === block.fontWeight &&
6051
+ style.fontStyle === block.fontStyle
4481
6052
  ) {
4482
6053
  return false;
4483
6054
  }
@@ -4615,7 +6186,7 @@ function tooNarrow(
4615
6186
  at += text.length;
4616
6187
  }
4617
6188
  if (WORD_CHAR.test(before) && WORD_CHAR.test(after)) return true;
4618
- return width > room + 0.5 && before !== '';
6189
+ return width > room + FIT_SLACK && before !== '';
4619
6190
  }
4620
6191
 
4621
6192
  const WORD_CHAR =