@react-x11/components 0.11.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 (100) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +26 -7
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +28 -3
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts.map +1 -1
  10. package/dist/html/css/cascade.js +19 -6
  11. package/dist/html/css/cascade.js.map +1 -1
  12. package/dist/html/css/color.d.ts +19 -0
  13. package/dist/html/css/color.d.ts.map +1 -1
  14. package/dist/html/css/color.js +86 -0
  15. package/dist/html/css/color.js.map +1 -1
  16. package/dist/html/css/parse.d.ts +11 -0
  17. package/dist/html/css/parse.d.ts.map +1 -1
  18. package/dist/html/css/parse.js +114 -4
  19. package/dist/html/css/parse.js.map +1 -1
  20. package/dist/html/css/style.d.ts +38 -1
  21. package/dist/html/css/style.d.ts.map +1 -1
  22. package/dist/html/css/style.js +326 -8
  23. package/dist/html/css/style.js.map +1 -1
  24. package/dist/html/css/ua.js +14 -0
  25. package/dist/html/css/ua.js.map +1 -1
  26. package/dist/html/css/values.d.ts +10 -1
  27. package/dist/html/css/values.d.ts.map +1 -1
  28. package/dist/html/css/values.js +27 -2
  29. package/dist/html/css/values.js.map +1 -1
  30. package/dist/html/css/vars.d.ts +5 -0
  31. package/dist/html/css/vars.d.ts.map +1 -1
  32. package/dist/html/css/vars.js +2 -2
  33. package/dist/html/css/vars.js.map +1 -1
  34. package/dist/html/dom.d.ts.map +1 -1
  35. package/dist/html/dom.js +35 -2
  36. package/dist/html/dom.js.map +1 -1
  37. package/dist/html/index.d.ts.map +1 -1
  38. package/dist/html/index.js +38 -1
  39. package/dist/html/index.js.map +1 -1
  40. package/dist/html/layout/block.d.ts +11 -2
  41. package/dist/html/layout/block.d.ts.map +1 -1
  42. package/dist/html/layout/block.js +181 -18
  43. package/dist/html/layout/block.js.map +1 -1
  44. package/dist/html/layout/boxes.d.ts +34 -6
  45. package/dist/html/layout/boxes.d.ts.map +1 -1
  46. package/dist/html/layout/boxes.js +35 -14
  47. package/dist/html/layout/boxes.js.map +1 -1
  48. package/dist/html/layout/cache.d.ts +2 -2
  49. package/dist/html/layout/cache.d.ts.map +1 -1
  50. package/dist/html/layout/cache.js +8 -3
  51. package/dist/html/layout/cache.js.map +1 -1
  52. package/dist/html/layout/floats.d.ts +4 -0
  53. package/dist/html/layout/floats.d.ts.map +1 -1
  54. package/dist/html/layout/floats.js +26 -0
  55. package/dist/html/layout/floats.js.map +1 -1
  56. package/dist/html/layout/inline.d.ts +26 -2
  57. package/dist/html/layout/inline.d.ts.map +1 -1
  58. package/dist/html/layout/inline.js +1222 -115
  59. package/dist/html/layout/inline.js.map +1 -1
  60. package/dist/html/node.d.ts +33 -0
  61. package/dist/html/node.d.ts.map +1 -1
  62. package/dist/html/node.js +512 -51
  63. package/dist/html/node.js.map +1 -1
  64. package/dist/html/paint.d.ts +53 -1
  65. package/dist/html/paint.d.ts.map +1 -1
  66. package/dist/html/paint.js +546 -180
  67. package/dist/html/paint.js.map +1 -1
  68. package/dist/html/surfaces.d.ts +7 -2
  69. package/dist/html/surfaces.d.ts.map +1 -1
  70. package/dist/html/surfaces.js +16 -0
  71. package/dist/html/surfaces.js.map +1 -1
  72. package/dist/internal/window.d.ts +22 -0
  73. package/dist/internal/window.d.ts.map +1 -1
  74. package/dist/internal/window.js +35 -8
  75. package/dist/internal/window.js.map +1 -1
  76. package/dist/richtext/node.d.ts +7 -0
  77. package/dist/richtext/node.d.ts.map +1 -1
  78. package/dist/richtext/node.js.map +1 -1
  79. package/package.json +5 -3
  80. package/src/flow/node.ts +80 -64
  81. package/src/html/controls.ts +50 -9
  82. package/src/html/css/cascade.ts +19 -6
  83. package/src/html/css/color.ts +87 -0
  84. package/src/html/css/parse.ts +107 -4
  85. package/src/html/css/style.ts +343 -8
  86. package/src/html/css/ua.ts +14 -0
  87. package/src/html/css/values.ts +31 -3
  88. package/src/html/css/vars.ts +2 -2
  89. package/src/html/dom.ts +38 -2
  90. package/src/html/index.ts +39 -1
  91. package/src/html/layout/block.ts +228 -16
  92. package/src/html/layout/boxes.ts +55 -21
  93. package/src/html/layout/cache.ts +8 -3
  94. package/src/html/layout/floats.ts +24 -0
  95. package/src/html/layout/inline.ts +1435 -108
  96. package/src/html/node.ts +535 -54
  97. package/src/html/paint.ts +650 -241
  98. package/src/html/surfaces.ts +21 -1
  99. package/src/internal/window.ts +36 -8
  100. package/src/richtext/node.ts +7 -0
@@ -29,11 +29,22 @@
29
29
  // *not* carry is its element: a layout outlives the parse it was made for
30
30
  // (`TextLayoutCache`), so the element under a run is found from its text's
31
31
  // place in the document (`LineText.spans`) instead.
32
+ import bidiModule from 'bidi-js';
32
33
  import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
33
34
  import { CONTAIN_LAYOUT, INLINE_BEFORE_ABSOLUTE, scrolls, } from '../css/style.js';
34
35
  import { inkColor, isTransparent, resolve } from '../css/values.js';
35
- import { BOX_RAISES, LINE_BOX_RAISES, isOffset, letteredAfter, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, transformInPlace, } from './boxes.js';
36
+ import { BOX_RAISES, LINE_BOX_RAISES, PADDED_FACES, isOffset, letteredAfter, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, transformInPlace, } from './boxes.js';
36
37
  import { tableGrid } from './grid.js';
38
+ /** How a paragraph's lines are fitted to their width, and measured: as a
39
+ * browser fits them (`FontsLike`'s `fit`). */
40
+ const PARAGRAPH_FIT = 'items';
41
+ /** How far past the room a line's content may reach and still fit it: a
42
+ * 64th of a pixel, the epsilon Blink's line breaker adds to the width it
43
+ * fits to (`LineBreaker::AvailableWidthToFit`), and the one the engine
44
+ * fits a paragraph's lines with. A line composed a piece at a time asks
45
+ * the same question of each piece, and half a pixel let a word run a
46
+ * third of one past the line a browser breaks before it. */
47
+ const FIT_SLACK = 1 / 64;
37
48
  /**
38
49
  * The text behind a layout, for the one caller that needs it: a selection
39
50
  * band has to turn a document offset into a **code point** index, which is
@@ -46,6 +57,20 @@ import { tableGrid } from './grid.js';
46
57
  */
47
58
  const LAYOUT_RUNS = new WeakMap();
48
59
  const WIDEST_WORD = new WeakMap();
60
+ /**
61
+ * A fragment of text that runs on from the text before it on its line, with
62
+ * no place to break between the two — only inline boxes' edges, or a float
63
+ * — and how wide those edges are: a word across a `<span>` with padding is
64
+ * in two fragments, and as wide as both and the padding between. What
65
+ * `joinsBefore` answers, for a min-content bound (`block.ts`'s `wordBound`)
66
+ * that would have the word no wider than either fragment's widest.
67
+ */
68
+ const JOINS = new WeakMap();
69
+ /** The edges a fragment of text runs on across from the text before it on
70
+ * its line, or undefined where it does not run on from it (`JOINS`). */
71
+ export function joinsBefore(text) {
72
+ return JOINS.get(text);
73
+ }
49
74
  /**
50
75
  * The widest word between spaces in a layout's text: its runs laid out
51
76
  * again with every space a line break, at no width limit. Where a text's
@@ -68,7 +93,10 @@ export function widestWord(fonts, layout, measured = true) {
68
93
  const words = fonts.layout(held.map((run) => ({
69
94
  ...run,
70
95
  text: run.text.replace(/[ \t\u200b]+/g, '\n'),
71
- })), {}, {});
96
+ })), {},
97
+ // measured as the lines are fitted, or a word across two elements is
98
+ // read narrower than it fits
99
+ { fit: PARAGRAPH_FIT });
72
100
  widest = 0;
73
101
  for (const line of words.lines)
74
102
  widest = Math.max(widest, line.width);
@@ -255,12 +283,17 @@ function linesOf(block, options, items, floatCount) {
255
283
  // searched for one where it cannot be
256
284
  let tabbed = false;
257
285
  let shadowed = false;
286
+ // and text that keeps its spaces where its lines wrap, whose spaces a
287
+ // line may end on (`hangPreserved`)
288
+ let keepsSpaces = false;
258
289
  for (const item of items) {
259
290
  if (item.kind !== 'text' || item.control)
260
291
  continue;
261
292
  const style = item.box.style;
262
293
  if (style.textShadow)
263
294
  shadowed = true;
295
+ if (style.whiteSpace === 'pre-wrap')
296
+ keepsSpaces = true;
264
297
  if (!tabbed &&
265
298
  (style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
266
299
  item.run.text.includes('\t')) {
@@ -295,6 +328,8 @@ function linesOf(block, options, items, floatCount) {
295
328
  firstLineColour(items, options.firstLine);
296
329
  const style = block.style;
297
330
  const wrapWords = overflowWrapOf(style, items);
331
+ // a line may break between any two letters, an edge between them or not
332
+ const breaksAnywhere = style.wordBreak === 'break-all' || style.lineBreakAnywhere;
298
333
  const base = {
299
334
  family: style.fontFamily,
300
335
  size: style.fontSize,
@@ -366,7 +401,7 @@ function linesOf(block, options, items, floatCount) {
366
401
  // every edge on the line of the content it belongs to (`layoutSpaced`);
367
402
  // the lines a piece at a time, below, where it did not.
368
403
  if (spaced && !floated && !indent && !alignedApart && !restyled) {
369
- const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts);
404
+ const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts, keepsSpaces);
370
405
  if (laid && !shortOfStrut(laid.lines, strut, firstStrut))
371
406
  return laid;
372
407
  if (laid)
@@ -402,12 +437,12 @@ function linesOf(block, options, items, floatCount) {
402
437
  const perLine = cut !== null && !clamp && hasNewline;
403
438
  // an embedding cannot be cut into chunks
404
439
  if (perLine && !hasControls(items)) {
405
- return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut });
440
+ return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut }, keepsSpaces);
406
441
  }
407
442
  if (perLine)
408
443
  cut = null;
409
444
  if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
410
- const chunked = layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts);
445
+ const chunked = layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, CHUNKS, keepsSpaces);
411
446
  if (!shortOfStrut(chunked.lines, strut, firstStrut))
412
447
  return chunked;
413
448
  strutted = true;
@@ -430,10 +465,19 @@ function linesOf(block, options, items, floatCount) {
430
465
  align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
431
466
  direction: style.direction,
432
467
  overflowWrap: cutWrap(style, cut, wrapWords),
468
+ fit: PARAGRAPH_FIT,
433
469
  ...cut,
434
470
  };
435
471
  let layout = fonts.layout(runs, base, layoutOptions);
436
472
  LAYOUT_RUNS.set(layout, runs);
473
+ const held = keepsSpaces
474
+ ? holdAtBreaks(layout, runs, spans, fonts, layoutOptions.maxWidth ?? options.width, !cut)
475
+ : null;
476
+ if (held) {
477
+ runs = held;
478
+ layout = fonts.layout(runs, base, layoutOptions);
479
+ LAYOUT_RUNS.set(layout, runs);
480
+ }
437
481
  // A clamp that cut the text short says so, for the clamp point after
438
482
  // it, and its last line ends in an ellipsis — as it does where the
439
483
  // clamp point falls just after the lines, more of the container after
@@ -480,6 +524,8 @@ function linesOf(block, options, items, floatCount) {
480
524
  : balanced
481
525
  ? unwrappedPlacer(style, options.width, false)
482
526
  : null);
527
+ if (keepsSpaces)
528
+ hangLines(lines, 0, runs, fonts, rtl);
483
529
  // a clamp and a cut are the one layout's to make
484
530
  if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
485
531
  strutted = true;
@@ -490,6 +536,10 @@ function linesOf(block, options, items, floatCount) {
490
536
  : { lines, height: layout.height, width: widest };
491
537
  }
492
538
  // --- the general case: line at a time -------------------------------------
539
+ // the paragraph's bidi levels, where anything in it can be reordered: a
540
+ // line is laid out a piece at a time here, and its pieces are ordered by
541
+ // them (`levelPieces`)
542
+ const paraBidi = fonts ? paragraphBidi(block, items, rtl) : null;
493
543
  const lines = [];
494
544
  let widest = 0;
495
545
  let y = 0;
@@ -550,6 +600,7 @@ function linesOf(block, options, items, floatCount) {
550
600
  align: 'left',
551
601
  direction: style.direction,
552
602
  overflowWrap: wrapWords,
603
+ fit: PARAGRAPH_FIT,
553
604
  },
554
605
  }
555
606
  : null;
@@ -561,6 +612,14 @@ function linesOf(block, options, items, floatCount) {
561
612
  * which `text-align-last` aligns. */
562
613
  const close = (last = false) => {
563
614
  const onFirst = lineOne !== null && !lines.length;
615
+ const levels = paraBidi
616
+ ? {
617
+ bidi: paraBidi,
618
+ fonts: fonts,
619
+ base: onFirst ? lineOne.base : base,
620
+ lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
621
+ }
622
+ : null;
564
623
  const line = finishLine(open, y,
565
624
  // the room beside the floats over the whole line box, which an
566
625
  // inline-block can make taller than its text (CSS 2.1 9.5)
@@ -570,7 +629,7 @@ function linesOf(block, options, items, floatCount) {
570
629
  ? onFirst
571
630
  ? lineOne.justify
572
631
  : lineJustify
573
- : null);
632
+ : null, levels);
574
633
  if (!line) {
575
634
  open = openLine(0);
576
635
  placeDeferred();
@@ -690,7 +749,12 @@ function linesOf(block, options, items, floatCount) {
690
749
  if (raise)
691
750
  placed.raise = raise;
692
751
  open.atomics.push(placed);
693
- open.order.push({ kind: 'atomic', at: open.x, item: placed });
752
+ open.order.push({
753
+ kind: 'atomic',
754
+ at: open.x,
755
+ item: placed,
756
+ ...(paraBidi ? { para: paraBidi.starts[index] } : null),
757
+ });
694
758
  open.x += outer;
695
759
  open.hang = 0;
696
760
  open.left = room.left;
@@ -731,7 +795,10 @@ function linesOf(block, options, items, floatCount) {
731
795
  !open.edges.length &&
732
796
  !pending.length &&
733
797
  !deferred.length &&
734
- !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
798
+ !(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
799
+ // and ordered alone as the paragraph orders it
800
+ (!paraBidi ||
801
+ orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
735
802
  if (tailIsPlain) {
736
803
  const tailOptions = {
737
804
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
@@ -739,20 +806,31 @@ function linesOf(block, options, items, floatCount) {
739
806
  align,
740
807
  direction: style.direction,
741
808
  overflowWrap: wrapWords,
809
+ fit: PARAGRAPH_FIT,
742
810
  };
743
- let layout = fonts.layout(segment.runs, base, tailOptions);
744
- LAYOUT_RUNS.set(layout, segment.runs);
811
+ let tailRuns = segment.runs;
812
+ let layout = fonts.layout(tailRuns, base, tailOptions);
813
+ LAYOUT_RUNS.set(layout, tailRuns);
814
+ const held = keepsSpaces
815
+ ? holdAtBreaks(layout, tailRuns, segment.spans, fonts, tailOptions.maxWidth ?? options.width, true)
816
+ : null;
817
+ if (held) {
818
+ tailRuns = held;
819
+ layout = fonts.layout(tailRuns, base, tailOptions);
820
+ LAYOUT_RUNS.set(layout, tailRuns);
821
+ }
745
822
  // the lines past the floats are justified as the lines beside them
746
823
  // were (`finishLine`), in the room they have
747
- layout = justifiedLayout(fonts, segment.runs, base, tailOptions, layout, Math.max(1, available), justify).layout;
824
+ layout = justifiedLayout(fonts, tailRuns, base, tailOptions, layout, Math.max(1, available), justify).layout;
748
825
  const tail = [];
826
+ const lift = layoutLift(layout);
749
827
  for (let i = 0; i < layout.lines.length; i += 1) {
750
828
  const natural = layout.lines[i];
751
829
  const text = {
752
830
  layout,
753
831
  layoutLine: i,
754
832
  drawX: band.left,
755
- drawY: y,
833
+ drawY: y - lift,
756
834
  textStart: segment.spans.documentAt(natural.start),
757
835
  textEnd: segment.spans.documentAt(natural.end),
758
836
  layoutStart: natural.start,
@@ -764,13 +842,15 @@ function linesOf(block, options, items, floatCount) {
764
842
  y: y + natural.y,
765
843
  width: natural.width,
766
844
  height: natural.height,
767
- baseline: natural.baseline - natural.y,
845
+ baseline: natural.baseline - natural.y - lift,
768
846
  texts: [text],
769
847
  textStart: text.textStart,
770
848
  textEnd: text.textEnd,
771
849
  atomics: [],
772
850
  });
773
851
  }
852
+ if (keepsSpaces)
853
+ hangLines(tail, 0, tailRuns, fonts, rtl);
774
854
  // or a line at a time, where one of them is shorter than the strut
775
855
  if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
776
856
  for (const line of tail) {
@@ -801,27 +881,72 @@ function linesOf(block, options, items, floatCount) {
801
881
  !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
802
882
  ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
803
883
  : 0;
804
- const fragment = fonts.layout(segment.runs, lineBase, {
805
- maxWidth: wraps(style) ? Math.max(1, room - tied) : undefined,
806
- lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
807
- // Never aligned by the text layout: the alignment belongs to the whole
808
- // line — its text, its atomics and its inline boxes' edges together —
809
- // which only this loop can see, and `finishLine` shifts it all. Laid
810
- // out aligned, the first fragment of a centred line was centred alone
811
- // and whatever followed it on the line was placed as though it had
812
- // not been, over it.
813
- align: 'left',
814
- direction: style.direction,
815
- overflowWrap: wrapWords,
816
- maxLines: 1,
817
- });
818
- LAYOUT_RUNS.set(fragment, segment.runs);
819
- const first = fragment.lines[0];
884
+ const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
885
+ let fragment = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, room - tied);
886
+ let first = fragment.lines[0];
820
887
  if (!first) {
821
888
  index = segment.nextIndex;
822
889
  offset = 0;
823
890
  continue;
824
891
  }
892
+ // Did the segment wrap? ntk answers with `truncated`. A layout that does
893
+ // not carry the flag — react-x11's Cocoa engine reports none — is asked
894
+ // the same of its line ends instead: it wrapped if anything but
895
+ // whitespace follows the first line. Read as "fitted", a cut fragment
896
+ // advanced past the whole segment, and a paragraph beside a float lost
897
+ // every line after its first. And a line that ends at a `<br>` is over
898
+ // whether or not anything followed it in this segment: what comes next
899
+ // — an atomic, an element's edge — is the next line's.
900
+ let wrapped = breakBefore(segment.runs, first.end) ||
901
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
902
+ // An inline box's edge is no place to break a line (CSS Text 3, 5.1):
903
+ // where the segment runs to its end on this line, its last word runs on
904
+ // past it, through the edges after it, into the text they stand before
905
+ // — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
906
+ // without the padding. Where the line may break at that text after all,
907
+ // the edges that close boxes are still the word's, as a box's end goes
908
+ // with the content it closes, and it is the ones that open a box that
909
+ // go with what follows. The line holds the word and all of that or none
910
+ // of it, so where it does not fit, the line breaks at the last place in
911
+ // the segment it may break, and the word goes to the next line whole.
912
+ // Where the segment has no such place, the word is the line's first,
913
+ // and is measured with what it runs into (below). A word that ends in
914
+ // white space hangs it, and its edges after it with it.
915
+ let glued = 0;
916
+ if (!wrapped &&
917
+ wraps(style) &&
918
+ lineItems[segment.nextIndex]?.kind === 'edge' &&
919
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
920
+ // none of it matters where the most it could come to fits: most
921
+ // edges are nowhere near a line's end
922
+ first.width + unbreakableBound(lineItems, segment.nextIndex) >
923
+ room + FIT_SLACK) {
924
+ const after = textAfterEdges(lineItems, segment.nextIndex);
925
+ glued =
926
+ after &&
927
+ !breaksBetween(fonts, lineBase, segment.runs, runsLength(segment.runs), after, breaksAnywhere)
928
+ ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
929
+ : closingEdges(lineItems, segment.nextIndex);
930
+ }
931
+ if (glued > 0 && first.width + glued > room + FIT_SLACK) {
932
+ // the last place it may break: laid out a hair narrower than it is,
933
+ // which the rest of the segment fits, not in the room the word
934
+ // leaves, where it broke after its first word if the word and what
935
+ // it runs into are wider than the line
936
+ const earlier = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, first.width - 0.5);
937
+ const line = earlier.lines[0];
938
+ // and not inside a word that `overflow-wrap` cut to fit: that is for
939
+ // a word no line holds, and the word and what it runs into may fit
940
+ // the next line
941
+ if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
942
+ fragment = earlier;
943
+ first = line;
944
+ wrapped = true;
945
+ glued = 0;
946
+ }
947
+ }
948
+ // the first word, with what it runs into past the segment
949
+ const word = first.width + glued;
825
950
  // Nothing fits beside the floats — the first word is wider than the
826
951
  // room they leave, and was either run past it or cut inside itself to
827
952
  // fit — so the line moves down to where a float ends, and tries again
@@ -830,7 +955,7 @@ function linesOf(block, options, items, floatCount) {
830
955
  // is `overflow-wrap`'s to break or to let run past.
831
956
  if (isEmpty(open) &&
832
957
  available < options.width &&
833
- tooNarrow(segment.runs, first.end, first.width, room)) {
958
+ tooNarrow(segment.runs, first.end, word, room)) {
834
959
  const below = belowFloats(options, y, guess);
835
960
  if (below !== null) {
836
961
  y = below;
@@ -839,27 +964,37 @@ function linesOf(block, options, items, floatCount) {
839
964
  }
840
965
  // An inline box's opening edge goes with the first word after it: where
841
966
  // the word does not fit the room the edge leaves, both go to the next
842
- // line, rather than the edge being left at this one's end. And a word
843
- // that may start a line — after a space the line ends on, or after an
844
- // atomic, which has a break after it (CSS Text 3, 5.1) — goes to the
845
- // next line whole where it does not fit what is left of this one,
846
- // rather than being broken inside itself to fit it: the text after an
847
- // inline-block, or after a float it was cut at, was its first letter
848
- // at the line's end and the rest on the next.
967
+ // line, rather than the edge being left at this one's end — where the
968
+ // line may break there at all. The edge is no place to break (CSS Text
969
+ // 3, 5.1), so that is where the line may break between what it ends on
970
+ // and the word: after white space or an atomic, or where the text
971
+ // engine breaks the two run together (`breaksBetween`). `ab<span
972
+ // style="padding: 0 4px">cd</span>` is one word, and a line too narrow
973
+ // for it runs past its end, as it does for the word without the
974
+ // padding; broken before the span, a float's min-content width was a
975
+ // letter wide, and it came out as wide as its room.
976
+ // And a word that may start a line — after a space the line ends on,
977
+ // or after an atomic, which has a break after it (CSS Text 3, 5.1) —
978
+ // goes to the next line whole where it does not fit what is left of
979
+ // this one, rather than being broken inside itself to fit it: the text
980
+ // after an inline-block, or after a float it was cut at, was its first
981
+ // letter at the line's end and the rest on the next.
849
982
  const startsLine = wraps(style) &&
850
983
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
851
984
  if (!isEmpty(open) &&
852
985
  (pendingWidth > 0
853
- ? tooNarrow(segment.runs, first.end, first.width, room)
986
+ ? tooNarrow(segment.runs, first.end, word, room) &&
987
+ breaksAfterLine(open, style, fonts, lineBase, segment.runs, breaksAnywhere)
854
988
  : startsLine &&
855
989
  // run past the room — its white space is no part of the width —
856
990
  // or cut inside itself to fit it
857
- (first.width > room + 0.5 ||
858
- tooNarrow(segment.runs, first.end, first.width, room)))) {
991
+ (word > room + FIT_SLACK ||
992
+ tooNarrow(segment.runs, first.end, word, room)))) {
859
993
  close();
860
994
  continue;
861
995
  }
862
996
  placePending(band.left);
997
+ const joins = joinsOnto(open, segment.runs, first.start);
863
998
  const placed = {
864
999
  layout: fragment,
865
1000
  layoutLine: 0,
@@ -872,26 +1007,35 @@ function linesOf(block, options, items, floatCount) {
872
1007
  spans: segment.spans,
873
1008
  };
874
1009
  segment.spans.giveGaps(placed, first.start, first.end);
1010
+ if (joins !== undefined)
1011
+ JOINS.set(placed, joins);
875
1012
  open.texts.push(placed);
876
1013
  open.order.push({
877
1014
  kind: 'text',
878
1015
  at: open.x,
879
1016
  item: placed,
880
1017
  reads: readingOf(segment.runs, first.start, first.end),
1018
+ ...(paraBidi
1019
+ ? {
1020
+ from: {
1021
+ para: paraBidi.starts[index] + offset,
1022
+ runs: segment.runs,
1023
+ spans: segment.spans,
1024
+ },
1025
+ }
1026
+ : null),
881
1027
  });
882
1028
  open.x += first.width;
883
1029
  open.left = band.left;
884
1030
  open.hang = 0;
885
- // Did the segment wrap? ntk answers with `truncated`. A layout that does
886
- // not carry the flag — react-x11's Cocoa engine reports none — is asked
887
- // the same of its line ends instead: it wrapped if anything but
888
- // whitespace follows the first line. Read as "fitted", a cut fragment
889
- // advanced past the whole segment, and a paragraph beside a float lost
890
- // every line after its first. And a line that ends at a `<br>` is over
891
- // whether or not anything followed it in this segment: what comes next
892
- // — an atomic, an element's edge — is the next line's.
893
- const wrapped = breakBefore(segment.runs, first.end) ||
894
- (fragment.truncated ?? inkBeyond(segment.runs, first.end));
1031
+ // the spaces `pre-wrap` keeps that it ends on, which the engine hung
1032
+ const hung = keepsSpaces
1033
+ ? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
1034
+ : NOTHING_HUNG;
1035
+ /** Before a forced break, the ones that fit take room (4.1.3). */
1036
+ const holdHung = () => {
1037
+ open.x += Math.min(hung.total, Math.max(0, available - open.x));
1038
+ };
895
1039
  if (!wrapped) {
896
1040
  // It fitted: the cursor stays on this line for whatever comes next.
897
1041
  //
@@ -905,7 +1049,11 @@ function linesOf(block, options, items, floatCount) {
905
1049
  const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
906
1050
  let trailing = '';
907
1051
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
908
- const text = segment.runs[i].text;
1052
+ // through the bidi controls after them, which take no room: the
1053
+ // engine strips the spaces before one as well
1054
+ const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
1055
+ if (!text)
1056
+ continue;
909
1057
  const m = hung.exec(text);
910
1058
  if (!m)
911
1059
  break;
@@ -915,18 +1063,29 @@ function linesOf(block, options, items, floatCount) {
915
1063
  }
916
1064
  if (trailing) {
917
1065
  // and noted: if what follows goes to the next line after all,
918
- // these spaces end this one, where CSS removes them (16.6.1)
1066
+ // these spaces end this one, where CSS removes them (16.6.1).
1067
+ // As wide as the engine says they make its line where the line
1068
+ // goes on, which a layout that fits as a browser does rounds with
1069
+ // the text they end, once (`advance`): the text rounded up to a
1070
+ // 64th and the spaces added after it came to more
919
1071
  open.hang =
920
- spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
921
- trailing.length;
1072
+ first.advance !== undefined
1073
+ ? first.advance - first.width
1074
+ : spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1075
+ trailing.length;
922
1076
  open.x += open.hang;
923
1077
  }
924
1078
  }
1079
+ else if (hung.total)
1080
+ holdHung();
925
1081
  index = segment.nextIndex;
926
1082
  offset = 0;
927
1083
  continue;
928
1084
  }
929
- close(breakBefore(segment.runs, first.end));
1085
+ const forced = breakBefore(segment.runs, first.end);
1086
+ if (forced && hung.total)
1087
+ holdHung();
1088
+ close(forced);
930
1089
  const advanced = advance(items, index, offset, first.end);
931
1090
  index = advanced.index;
932
1091
  offset = advanced.offset;
@@ -943,6 +1102,27 @@ function linesOf(block, options, items, floatCount) {
943
1102
  return { lines, height: y, width: widest, floatRow };
944
1103
  }
945
1104
  const EMPTY = { lines: [], height: 0, width: 0 };
1105
+ /** The first line of a segment's text in a width, as the line at a time
1106
+ * lays one out (`linesOf`). */
1107
+ function fragmentLayout(fonts, runs, base, style, lineHeight, overflowWrap, width) {
1108
+ const laid = fonts.layout(runs, base, {
1109
+ maxWidth: wraps(style) ? Math.max(1, width) : undefined,
1110
+ lineHeight,
1111
+ // Never aligned by the text layout: the alignment belongs to the whole
1112
+ // line — its text, its atomics and its inline boxes' edges together —
1113
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1114
+ // out aligned, the first fragment of a centred line was centred alone
1115
+ // and whatever followed it on the line was placed as though it had not
1116
+ // been, over it.
1117
+ align: 'left',
1118
+ direction: style.direction,
1119
+ overflowWrap,
1120
+ fit: PARAGRAPH_FIT,
1121
+ maxLines: 1,
1122
+ });
1123
+ LAYOUT_RUNS.set(laid, runs);
1124
+ return laid;
1125
+ }
946
1126
  /**
947
1127
  * Which line each float a paragraph placed stands beside: the one whose top
948
1128
  * it went at, whose content moved over for it — added up on the line, for
@@ -1050,9 +1230,9 @@ function spacersHold(style, items) {
1050
1230
  * (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
1051
1231
  * keep it there.
1052
1232
  */
1053
- function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1233
+ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts, keepsSpaces) {
1054
1234
  const wrapWords = overflowWrapOf(style, items);
1055
- const runs = [];
1235
+ let runs = [];
1056
1236
  const spans = new SpanMap();
1057
1237
  const spacers = [];
1058
1238
  let doc = 0;
@@ -1081,12 +1261,22 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1081
1261
  align,
1082
1262
  direction: style.direction,
1083
1263
  overflowWrap: wrapWords,
1264
+ fit: PARAGRAPH_FIT,
1084
1265
  };
1085
1266
  let layout = fonts.layout(runs, base, layoutOptions);
1086
1267
  LAYOUT_RUNS.set(layout, runs);
1268
+ const held = keepsSpaces
1269
+ ? holdAtBreaks(layout, runs, spans, fonts, width, true)
1270
+ : null;
1271
+ if (held) {
1272
+ runs = held;
1273
+ layout = fonts.layout(runs, base, layoutOptions);
1274
+ LAYOUT_RUNS.set(layout, runs);
1275
+ }
1087
1276
  // justified with its spacers in it, which are edges and not spaces
1088
1277
  layout = justifiedLayout(fonts, runs, base, layoutOptions, layout, width, justification(style), new Set(spacers.map((spacer) => spacer.at))).layout;
1089
1278
  const place = wraps(style) ? null : unwrappedPlacer(style, width);
1279
+ const lift = layoutLift(layout);
1090
1280
  let offsets = null;
1091
1281
  const lines = [];
1092
1282
  let widest = 0;
@@ -1117,7 +1307,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1117
1307
  layout,
1118
1308
  layoutLine: i,
1119
1309
  drawX: dx,
1120
- drawY: 0,
1310
+ drawY: -lift,
1121
1311
  textStart: spans.documentAt(natural.start),
1122
1312
  textEnd: spans.documentAt(natural.end),
1123
1313
  layoutStart: natural.start,
@@ -1129,7 +1319,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1129
1319
  y: natural.y,
1130
1320
  width: natural.width,
1131
1321
  height: natural.height,
1132
- baseline: natural.baseline - natural.y,
1322
+ baseline: natural.baseline - natural.y - lift,
1133
1323
  texts: [text],
1134
1324
  textStart: text.textStart,
1135
1325
  textEnd: text.textEnd,
@@ -1138,6 +1328,8 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1138
1328
  });
1139
1329
  widest = Math.max(widest, natural.width);
1140
1330
  }
1331
+ if (keepsSpaces)
1332
+ hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
1141
1333
  return { lines, height: layout.height, width: widest };
1142
1334
  }
1143
1335
  /** The code point a code-unit offset into a layout's text starts. */
@@ -1182,6 +1374,7 @@ export function layoutOffsetOf(text, doc, end = false) {
1182
1374
  * `place` says; returns the widest. */
1183
1375
  function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1184
1376
  let widest = 0;
1377
+ const lift = layoutLift(layout);
1185
1378
  for (let i = 0; i < layout.lines.length; i += 1) {
1186
1379
  const natural = layout.lines[i];
1187
1380
  const dx = place ? place(natural) : 0;
@@ -1189,7 +1382,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1189
1382
  layout,
1190
1383
  layoutLine: i,
1191
1384
  drawX: xOff + dx,
1192
- drawY: yOff,
1385
+ drawY: yOff - lift,
1193
1386
  textStart: spans.documentAt(natural.start),
1194
1387
  textEnd: spans.documentAt(natural.end),
1195
1388
  layoutStart: natural.start,
@@ -1201,7 +1394,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1201
1394
  y: yOff + natural.y,
1202
1395
  width: natural.width,
1203
1396
  height: natural.height,
1204
- baseline: natural.baseline - natural.y,
1397
+ baseline: natural.baseline - natural.y - lift,
1205
1398
  texts: [text],
1206
1399
  textStart: text.textStart,
1207
1400
  textEnd: text.textEnd,
@@ -1237,7 +1430,7 @@ const CHUNKS = {
1237
1430
  * breaking never crosses a required break), so the chunk seams are
1238
1431
  * invisible; each chunk stays a single batch a viewport can keep whole.
1239
1432
  */
1240
- function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS) {
1433
+ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS, keepsSpaces = false) {
1241
1434
  const wrapWords = overflowWrapOf(style, items);
1242
1435
  const lines = [];
1243
1436
  let widest = 0;
@@ -1248,7 +1441,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1248
1441
  let hardLines = 0;
1249
1442
  const { cut } = chunking;
1250
1443
  const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
1251
- const flush = () => {
1444
+ const flush = (last = false) => {
1252
1445
  if (!chunkRuns.length)
1253
1446
  return;
1254
1447
  const chunkOptions = {
@@ -1257,14 +1450,27 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1257
1450
  align,
1258
1451
  direction: style.direction,
1259
1452
  overflowWrap: cutWrap(style, cut, wrapWords),
1453
+ fit: PARAGRAPH_FIT,
1260
1454
  ...cut,
1261
1455
  };
1262
1456
  let layout = fonts.layout(chunkRuns, base, chunkOptions);
1263
1457
  LAYOUT_RUNS.set(layout, chunkRuns);
1458
+ const held = keepsSpaces
1459
+ ? holdAtBreaks(layout, chunkRuns, chunkSpans, fonts, chunkOptions.maxWidth ?? width, last && !cut)
1460
+ : null;
1461
+ if (held) {
1462
+ chunkRuns = held;
1463
+ layout = fonts.layout(chunkRuns, base, chunkOptions);
1464
+ LAYOUT_RUNS.set(layout, chunkRuns);
1465
+ }
1264
1466
  // a chunk ends at a forced break or at the paragraph's end, which is
1265
1467
  // where `justifiedRuns` takes a chunk's last line to end
1266
1468
  layout = justifiedLayout(fonts, chunkRuns, base, chunkOptions, layout, width, justification(style)).layout;
1469
+ const from = lines.length;
1267
1470
  widest = Math.max(widest, emitLayout(layout, chunkSpans, 0, y, lines, place));
1471
+ if (keepsSpaces) {
1472
+ hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
1473
+ }
1268
1474
  y += layout.height;
1269
1475
  chunkRuns = [];
1270
1476
  chunkSpans = new SpanMap();
@@ -1324,7 +1530,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1324
1530
  text = text.slice(cut);
1325
1531
  }
1326
1532
  }
1327
- flush();
1533
+ flush(true);
1328
1534
  return { lines, height: y, width: widest };
1329
1535
  }
1330
1536
  function openLine(indent) {
@@ -1345,6 +1551,250 @@ function breaksAtEnd(open, style) {
1345
1551
  return (wraps(style) &&
1346
1552
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic'));
1347
1553
  }
1554
+ /**
1555
+ * Whether a line may break before an inline box's opening edge, between
1556
+ * what the line has got to and `next`, the text after the edge: after an
1557
+ * atomic, or after the white space the line ends on, as `breaksAtEnd`
1558
+ * says, or where the text engine breaks the line's last text and `next`
1559
+ * run together (`breaksBetween`). An edge on the way is no place to break
1560
+ * of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
1561
+ * yet has none to hold on to.
1562
+ */
1563
+ function breaksAfterLine(open, style, fonts, base, next, anywhere) {
1564
+ if (!wraps(style))
1565
+ return false;
1566
+ if (open.hang > 0)
1567
+ return true;
1568
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1569
+ const placed = open.order[i];
1570
+ if (placed.kind === 'edge')
1571
+ continue;
1572
+ if (placed.kind === 'atomic')
1573
+ return true;
1574
+ const text = placed.item;
1575
+ const runs = LAYOUT_RUNS.get(text.layout);
1576
+ if (!runs || (runs.length > 0 && typeof runs[0] === 'number'))
1577
+ return true;
1578
+ const end = text.layout.lines[text.layoutLine].end;
1579
+ return breaksBetween(fonts, base, runs, end, next, anywhere);
1580
+ }
1581
+ return true;
1582
+ }
1583
+ /**
1584
+ * How wide the edges are between the text the open line ends on and text
1585
+ * starting at `start` in `runs`, where the one runs on into the other
1586
+ * (`JOINS`): neither is white space at the join, and no atomic, which has a
1587
+ * break before and after it, is between them. Undefined where they do not
1588
+ * join, or the line has no text yet. Letters on either side are taken to
1589
+ * join whatever they are, which may count an ideograph's join that the
1590
+ * engine would break at: it makes the bound it is for a larger one, never a
1591
+ * smaller.
1592
+ */
1593
+ function joinsOnto(open, runs, start) {
1594
+ if (open.hang > 0)
1595
+ return undefined;
1596
+ const next = charAt(runs, start);
1597
+ if (next === '' || SPACE.test(next))
1598
+ return undefined;
1599
+ let edges = 0;
1600
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1601
+ const placed = open.order[i];
1602
+ if (placed.kind === 'edge') {
1603
+ edges += placed.item.width;
1604
+ continue;
1605
+ }
1606
+ if (placed.kind === 'atomic')
1607
+ return undefined;
1608
+ const text = placed.item;
1609
+ const before = LAYOUT_RUNS.get(text.layout);
1610
+ if (!before || (before.length > 0 && typeof before[0] === 'number')) {
1611
+ return undefined;
1612
+ }
1613
+ const last = charAt(before, text.layout.lines[text.layoutLine].end - 1);
1614
+ return last === '' || SPACE.test(last) ? undefined : edges;
1615
+ }
1616
+ return undefined;
1617
+ }
1618
+ /**
1619
+ * Whether a line may break between two texts an inline box's edge stands
1620
+ * between — the text of `before` up to the code unit `end`, and `after` —
1621
+ * which is where it may break them run together, the edge being no place
1622
+ * to break (CSS Text 3, 5.1). After white space, and not before it, where
1623
+ * the break is after that white space instead (UAX #14, LB7). Never between
1624
+ * two letters or digits of the alphabets whose words break only at spaces,
1625
+ * which is every edge in running text, and needs no asking. Anything else
1626
+ * — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
1627
+ * of the text engine: the two words, laid out at no width, break at every
1628
+ * place a line may, which is UAX #14 as the `linebreak` package has it on
1629
+ * both backends — ntk breaks every line with it, and react-x11's CoreText
1630
+ * engine a text at no width. So an edge after a hyphen, or between two
1631
+ * ideographs, is a place to break exactly where the same text with no edge
1632
+ * in it is one; a list of classes here would be a third opinion.
1633
+ *
1634
+ * Counted, not located: the words run together break into as many pieces
1635
+ * as the two apart where they break between them, and one fewer where the
1636
+ * last piece of the one runs on into the first of the other. The line
1637
+ * offsets are no answer, as they are not the text's on every backend: the
1638
+ * CoreText engine lays out a copy with a line break put in at each place,
1639
+ * and its offsets are that copy's.
1640
+ */
1641
+ function breaksBetween(fonts, base, before, end, after, anywhere) {
1642
+ const last = charAt(before, end - 1);
1643
+ const next = charAt(after, 0);
1644
+ // nothing to hold the two together
1645
+ if (last === '' || next === '')
1646
+ return true;
1647
+ if (SPACE.test(last))
1648
+ return true;
1649
+ if (SPACE.test(next))
1650
+ return false;
1651
+ if (anywhere)
1652
+ return true;
1653
+ if (HOLDS.test(last) && HOLDS.test(next))
1654
+ return false;
1655
+ if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next))
1656
+ return false;
1657
+ const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
1658
+ const head = sliceRuns(after, 0, firstSpace(after));
1659
+ const pieces = (runs) => fonts.layout(runs, base, {
1660
+ maxWidth: MIN_CONTENT_WIDTH,
1661
+ overflowWrap: 'normal',
1662
+ }).lines.length;
1663
+ return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
1664
+ }
1665
+ /** The width a text is laid out at to break it at every place it may
1666
+ * break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
1667
+ * `MIN_CONTENT_PROBE`). */
1668
+ const MIN_CONTENT_WIDTH = 0;
1669
+ /** White space a line breaks after. */
1670
+ const SPACE = /^[ \t\n]$/;
1671
+ /** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
1672
+ * marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
1673
+ * LB25, LB28). */
1674
+ const HOLDS = /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
1675
+ /** What no line breaks after, whatever follows: an opening bracket, a
1676
+ * straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
1677
+ const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
1678
+ /** What no line breaks before, whatever precedes: a closing bracket, a
1679
+ * stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
1680
+ * LB13, LB19) — the punctuation after a link or a `<code>`, which needs
1681
+ * no asking either. */
1682
+ const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
1683
+ /** The code unit at an offset into runs' joined text, or '' outside it. */
1684
+ function charAt(runs, at) {
1685
+ if (at < 0)
1686
+ return '';
1687
+ let from = 0;
1688
+ for (const run of runs) {
1689
+ const next = from + run.text.length;
1690
+ if (at < next)
1691
+ return run.text[at - from];
1692
+ from = next;
1693
+ }
1694
+ return '';
1695
+ }
1696
+ /** Where the last white space before a code-unit offset is, or -1. */
1697
+ function lastSpace(runs, end) {
1698
+ let found = -1;
1699
+ let from = 0;
1700
+ for (const run of runs) {
1701
+ if (from >= end)
1702
+ break;
1703
+ const text = run.text.slice(0, end - from);
1704
+ const at = Math.max(text.lastIndexOf(' '), text.lastIndexOf('\t'), text.lastIndexOf('\n'));
1705
+ if (at >= 0)
1706
+ found = from + at;
1707
+ from += run.text.length;
1708
+ }
1709
+ return found;
1710
+ }
1711
+ /** Where the first white space in runs' joined text is, or its length. */
1712
+ function firstSpace(runs) {
1713
+ let from = 0;
1714
+ for (const run of runs) {
1715
+ const at = run.text.search(/[ \t\n]/);
1716
+ if (at >= 0)
1717
+ return from + at;
1718
+ from += run.text.length;
1719
+ }
1720
+ return from;
1721
+ }
1722
+ function runsLength(runs) {
1723
+ let length = 0;
1724
+ for (const run of runs)
1725
+ length += run.text.length;
1726
+ return length;
1727
+ }
1728
+ /**
1729
+ * The text inline boxes' edges from `from` on stand before, up to its first
1730
+ * white space: what the text before the edges runs on into. Null where an
1731
+ * atomic comes first, which has a break before it, or nothing does.
1732
+ */
1733
+ function textAfterEdges(items, from) {
1734
+ const runs = [];
1735
+ for (let i = from; i < items.length; i += 1) {
1736
+ const item = items[i];
1737
+ if (item.kind === 'edge' || item.kind === 'float')
1738
+ continue;
1739
+ if (item.kind === 'atomic')
1740
+ break;
1741
+ const text = item.run.text;
1742
+ const stop = text.search(/[ \t\n]/);
1743
+ if (stop < 0) {
1744
+ runs.push(item.run);
1745
+ continue;
1746
+ }
1747
+ runs.push({ ...item.run, text: text.slice(0, stop + 1) });
1748
+ break;
1749
+ }
1750
+ return runs.length ? runs : null;
1751
+ }
1752
+ /**
1753
+ * The most `unbreakableAfter` could come to, with no layout: the edges on
1754
+ * the way, and the text up to its first white space at half again its size
1755
+ * a character, which no glyph a document sets words in is wider than, and
1756
+ * its letter spacing (`longestWord`'s estimate).
1757
+ */
1758
+ function unbreakableBound(items, from) {
1759
+ let width = 0;
1760
+ for (let i = from; i < items.length; i += 1) {
1761
+ const item = items[i];
1762
+ if (item.kind === 'float')
1763
+ continue;
1764
+ if (item.kind === 'edge') {
1765
+ width += Math.max(0, item.width);
1766
+ continue;
1767
+ }
1768
+ if (item.kind === 'atomic')
1769
+ break;
1770
+ const run = item.run;
1771
+ const stop = run.text.search(/[ \t\n]/);
1772
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
1773
+ width += (stop < 0 ? run.text.length : stop) * per;
1774
+ if (stop >= 0)
1775
+ break;
1776
+ }
1777
+ return width;
1778
+ }
1779
+ /** How wide the edges from `from` on are that close inline boxes, up to
1780
+ * one that opens a box or anything but an edge or a float. */
1781
+ function closingEdges(items, from) {
1782
+ let width = 0;
1783
+ for (let i = from; i < items.length; i += 1) {
1784
+ const item = items[i];
1785
+ if (item.kind === 'float')
1786
+ continue;
1787
+ if (item.kind !== 'edge' || item.side !== 'end')
1788
+ break;
1789
+ width += item.width;
1790
+ }
1791
+ return width;
1792
+ }
1793
+ /** Whether a code-unit offset into runs' joined text is between two
1794
+ * letters or digits: inside a word, where only `overflow-wrap` breaks. */
1795
+ function insideWord(runs, at) {
1796
+ return (WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at)));
1797
+ }
1348
1798
  /**
1349
1799
  * How wide the content from `from` on is up to where its line may break:
1350
1800
  * its text up to a space it may break at — one `holdNoWrap` left a space —
@@ -1380,7 +1830,9 @@ function unbreakableAfter(items, from, style, fonts, base) {
1380
1830
  runs.push({ ...item.run, text: text.slice(0, stop) });
1381
1831
  break;
1382
1832
  }
1383
- return runs.length ? width + fonts.layout(runs, base, {}).width : width;
1833
+ return runs.length
1834
+ ? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
1835
+ : width;
1384
1836
  }
1385
1837
  /**
1386
1838
  * Close a line: its height and baseline from what is on it, then where it
@@ -1388,11 +1840,16 @@ function unbreakableAfter(items, from, style, fonts, base) {
1388
1840
  * it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
1389
1841
  * centred), moved onto every text, atomic and edge on it at once.
1390
1842
  */
1391
- function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null) {
1843
+ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null,
1844
+ /** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
1845
+ levels = null) {
1392
1846
  if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1393
1847
  return null;
1394
1848
  }
1395
- const reordered = open.order.some((p) => needsOrdering(p, rtl));
1849
+ const reordered = levels
1850
+ ? levelPieces(open, levels) ||
1851
+ open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
1852
+ : open.order.some((p) => needsOrdering(p, rtl));
1396
1853
  if (reordered)
1397
1854
  reorderLine(open, rtl);
1398
1855
  // Every line box starts with the block's strut, its face at its line
@@ -1435,7 +1892,9 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1435
1892
  continue;
1436
1893
  }
1437
1894
  const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1438
- const own = natural.baseline - natural.y;
1895
+ // with its leading shared as the strut's is: it is drawn on the line's
1896
+ // baseline, wherever its engine put its own
1897
+ const own = lineAscent(natural);
1439
1898
  ascent = Math.max(ascent, own);
1440
1899
  descent = Math.max(descent, natural.height - own);
1441
1900
  }
@@ -1601,10 +2060,8 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1601
2060
  * UAX #9's L2 over pieces rather than characters: a fragment is in order
1602
2061
  * inside itself already — the engine laid it out bidirectionally — so what
1603
2062
  * is left to order is the fragments and the atomics between them, each at
1604
- * a level — a fragment the direction its letters read in, or the
1605
- * paragraph's where they read both ways, and a neutral (an atomic, a
1606
- * fragment of spaces and digits) its neighbours' where they agree and the
1607
- * paragraph's where they do not (N1, N2).
2063
+ * a level: the paragraph's own, where it has them (`levelPieces`), and
2064
+ * otherwise the one it reads at (`readingLevels`).
1608
2065
  *
1609
2066
  * Then each element's edges, innermost first, around what of it is on the
1610
2067
  * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
@@ -1623,28 +2080,16 @@ function reorderLine(open, rtl) {
1623
2080
  for (let i = 0; i < n; i += 1)
1624
2081
  if (order[i].kind !== 'edge')
1625
2082
  content.push(i);
1626
- const strong = content.map((i) => {
2083
+ // UAX #9's levels where the paragraph's are known (`levelPieces`), each
2084
+ // piece at one of them; its reading otherwise
2085
+ const given = content.map((i) => {
1627
2086
  const p = order[i];
1628
- if (p.kind !== 'text' || p.reads === null)
1629
- return null;
1630
- return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
1631
- });
1632
- const strongAt = (from, step) => {
1633
- for (let k = from + step; k >= 0 && k < content.length; k += step) {
1634
- if (strong[k] !== null)
1635
- return strong[k];
1636
- }
1637
- return rtl;
1638
- };
1639
- const isRtl = strong.map((r, k) => {
1640
- if (r !== null)
1641
- return r;
1642
- const before = strongAt(k, -1);
1643
- return before === strongAt(k, 1) ? before : rtl;
2087
+ return p.kind === 'edge' ? undefined : p.level;
1644
2088
  });
1645
- // R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
1646
- // in a right-to-left one
1647
- const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
2089
+ const levels = given.every((level) => level !== undefined)
2090
+ ? given
2091
+ : readingLevels(order, content, rtl);
2092
+ const isRtl = levels.map((level) => (level & 1) === 1);
1648
2093
  const visual = content.map((_, k) => k);
1649
2094
  for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
1650
2095
  for (let a = 0; a < visual.length;) {
@@ -1719,6 +2164,9 @@ function reorderLine(open, rtl) {
1719
2164
  if (isRtl[k] && natural)
1720
2165
  dx += Math.max(0, room - natural.width);
1721
2166
  piece.item.drawX += dx;
2167
+ // and the spaces it hung are on the side of it its room is left on
2168
+ if (piece.item.hung)
2169
+ placeHung(piece.item, isRtl[k]);
1722
2170
  }
1723
2171
  else {
1724
2172
  piece.item.x += dx;
@@ -1726,6 +2174,399 @@ function reorderLine(open, rtl) {
1726
2174
  x += room;
1727
2175
  }
1728
2176
  }
2177
+ /**
2178
+ * The levels of a line's pieces from which way each reads, where the
2179
+ * paragraph's are not known: a fragment the direction its letters read in,
2180
+ * or the paragraph's where they read both ways, and a neutral — an atomic, a
2181
+ * fragment of spaces and digits — its neighbours' where they agree and the
2182
+ * paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
2183
+ * L is 0 in a left-to-right one and 2 in a right-to-left one.
2184
+ */
2185
+ function readingLevels(order, content, rtl) {
2186
+ const strong = content.map((i) => {
2187
+ const p = order[i];
2188
+ if (p.kind !== 'text' || p.reads === null)
2189
+ return null;
2190
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2191
+ });
2192
+ const strongAt = (from, step) => {
2193
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2194
+ if (strong[k] !== null)
2195
+ return strong[k];
2196
+ }
2197
+ return rtl;
2198
+ };
2199
+ return strong.map((r, k) => {
2200
+ const reads = r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
2201
+ return reads ? 1 : rtl ? 2 : 0;
2202
+ });
2203
+ }
2204
+ let BIDI = null;
2205
+ /** bidi-js, made the first time a paragraph needs it. Its declarations
2206
+ * give a CommonJS module a default export, which NodeNext reads as the
2207
+ * module object; Node and a bundler both hand over its factory. */
2208
+ function bidiJs() {
2209
+ if (!BIDI) {
2210
+ const factory = bidiModule;
2211
+ BIDI = (typeof factory === 'function' ? factory : factory.default)();
2212
+ }
2213
+ return BIDI;
2214
+ }
2215
+ /** A paragraph's levels, or null where nothing in it can be reordered:
2216
+ * it reads left to right, and holds no right-to-left letter and no bidi
2217
+ * control. Kept on its block, and resolved again only where its text is
2218
+ * not what it was: a pass at another width has the same. */
2219
+ function paragraphBidi(block, items, rtl) {
2220
+ let reorders = rtl;
2221
+ for (let i = 0; !reorders && i < items.length; i += 1) {
2222
+ const item = items[i];
2223
+ if (item.kind === 'text' && REORDERS.test(item.run.text))
2224
+ reorders = true;
2225
+ }
2226
+ if (!reorders)
2227
+ return null;
2228
+ const starts = [];
2229
+ let text = '';
2230
+ for (const item of items) {
2231
+ starts.push(text.length);
2232
+ if (item.kind === 'text')
2233
+ text += item.run.text;
2234
+ else if (item.kind === 'atomic')
2235
+ text += '\ufffc';
2236
+ }
2237
+ const base = rtl ? 1 : 0;
2238
+ const kept = PARAGRAPH_BIDI.get(block);
2239
+ if (kept && kept.text === text && kept.base === base) {
2240
+ return { ...kept, starts };
2241
+ }
2242
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2243
+ const explicit = EXPLICIT.test(text);
2244
+ const bidi = { text, levels, explicit, base, starts };
2245
+ PARAGRAPH_BIDI.set(block, bidi);
2246
+ return bidi;
2247
+ }
2248
+ const PARAGRAPH_BIDI = new WeakMap();
2249
+ /** The explicit embeddings, overrides and isolates. */
2250
+ const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
2251
+ /** The explicit embeddings, overrides and isolates a text ends on. */
2252
+ const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
2253
+ /** Whether a character is one X9 removes, or an isolate: a bidi control,
2254
+ * or a boundary neutral such as a zero-width space or a soft hyphen. */
2255
+ function isControl(c) {
2256
+ return ((c >= 0x202a && c <= 0x202e) ||
2257
+ (c >= 0x2066 && c <= 0x2069) ||
2258
+ (c >= 0x200b && c <= 0x200d) ||
2259
+ (c >= 0x2060 && c <= 0x2064) ||
2260
+ c === 0xad ||
2261
+ c === 0xfeff ||
2262
+ c === 0x180e);
2263
+ }
2264
+ /** The paired brackets UAX #9 resolves together (N0), which may pair
2265
+ * across a piece's end. */
2266
+ const BRACKETS = /[()[\]{}\u0f3a-\u0f3d\u169b\u169c\u2045\u2046\u207d\u207e\u208d\u208e\u2308-\u230b\u2329\u232a\u2768-\u2775\u27c5\u27c6\u27e6-\u27ef\u2983-\u2998\u29d8-\u29db\u29fc\u29fd\u2e22-\u2e29\u3008-\u3011\u3014-\u301b\ufe59-\ufe5e\uff08\uff09\uff3b\uff3d\uff5b\uff5d\uff5f\uff60\uff62\uff63]/;
2267
+ /** The strong types, which nothing around them resolves again. */
2268
+ const STRONG = new Set(['L', 'R', 'AL']);
2269
+ /** What UAX #9 L1 puts back at the paragraph's level where a line ends on
2270
+ * it: white space, the isolates, and what X9 removed. */
2271
+ const TRAILING = new Set([
2272
+ 'WS',
2273
+ 'S',
2274
+ 'B',
2275
+ 'LRI',
2276
+ 'RLI',
2277
+ 'FSI',
2278
+ 'PDI',
2279
+ 'BN',
2280
+ 'LRE',
2281
+ 'RLE',
2282
+ 'LRO',
2283
+ 'RLO',
2284
+ 'PDF',
2285
+ ]);
2286
+ /** A segment's text, and its levels as the engine resolves them, laid out
2287
+ * alone in the paragraph's direction. */
2288
+ const ALONE = new WeakMap();
2289
+ function aloneLevels(runs, rtl) {
2290
+ let alone = ALONE.get(runs);
2291
+ if (!alone) {
2292
+ const text = runs.map((run) => run.text).join('');
2293
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2294
+ alone = { text, levels };
2295
+ ALONE.set(runs, alone);
2296
+ }
2297
+ return alone;
2298
+ }
2299
+ /** Whether the text from a segment on, laid out alone, is ordered as the
2300
+ * paragraph orders it: every letter at the level it has there. */
2301
+ function orderedAlone(bidi, segment, para, rtl) {
2302
+ const { text, levels } = aloneLevels(segment.runs, rtl);
2303
+ for (let at = 0; at < levels.length; at += 1) {
2304
+ if (segment.spans.isGap(at))
2305
+ continue;
2306
+ if (levels[at] === bidi.levels[para + at])
2307
+ continue;
2308
+ if (!isControl(text.charCodeAt(at)))
2309
+ return false;
2310
+ }
2311
+ return true;
2312
+ }
2313
+ /**
2314
+ * Give each piece on a line its level, and answer whether the line is to be
2315
+ * put in visual order at all. A piece's letters have the paragraph's levels,
2316
+ * with the white space the line ends on back at the paragraph's (L1). A
2317
+ * fragment was laid out alone and ordered inside itself by the engine: it
2318
+ * is kept whole where the engine's levels for it are the paragraph's and it
2319
+ * begins and ends at its lowest one, so that nothing of another piece comes
2320
+ * between its letters (L2), and it is the line's at that level. It is split
2321
+ * otherwise, a layout to each run of its letters at one level, held in that
2322
+ * level's direction by an override; the controls in it, which take no
2323
+ * room, are left out. An atomic is at its replacement character's level.
2324
+ */
2325
+ function levelPieces(open, line) {
2326
+ const { bidi } = line;
2327
+ let start = Infinity;
2328
+ let end = -Infinity;
2329
+ for (const p of open.order) {
2330
+ if (p.kind === 'text' && p.from) {
2331
+ const natural = p.item.layout.lines[p.item.layoutLine];
2332
+ if (!natural)
2333
+ continue;
2334
+ start = Math.min(start, p.from.para + natural.start);
2335
+ end = Math.max(end, p.from.para + natural.end);
2336
+ }
2337
+ else if (p.kind === 'atomic' && p.para !== undefined) {
2338
+ start = Math.min(start, p.para);
2339
+ end = Math.max(end, p.para + 1);
2340
+ }
2341
+ }
2342
+ // an element's edges alone are ordered as the paragraph reads
2343
+ if (!(end > start))
2344
+ return bidi.base > 0;
2345
+ const levels = bidi.levels.slice(start, end);
2346
+ const js = bidiJs();
2347
+ for (let i = end - 1; i >= start; i -= 1) {
2348
+ if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i])))
2349
+ break;
2350
+ levels[i - start] = bidi.base;
2351
+ }
2352
+ const levelAt = (at) => levels[at - start];
2353
+ let off = false;
2354
+ const order = [];
2355
+ const texts = [];
2356
+ for (const p of open.order) {
2357
+ if (p.kind === 'edge') {
2358
+ order.push(p);
2359
+ continue;
2360
+ }
2361
+ if (p.kind === 'atomic') {
2362
+ p.level = p.para === undefined ? bidi.base : levelAt(p.para);
2363
+ off ||= p.level > 0;
2364
+ order.push(p);
2365
+ continue;
2366
+ }
2367
+ for (const piece of levelled(p, levelAt, line)) {
2368
+ off ||= piece.level > 0;
2369
+ order.push(piece);
2370
+ texts.push(piece.item);
2371
+ }
2372
+ }
2373
+ open.order = order;
2374
+ open.texts = texts;
2375
+ return off;
2376
+ }
2377
+ /** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
2378
+ function levelled(p, levelAt, line) {
2379
+ const text = p.item;
2380
+ const natural = text.layout.lines[text.layoutLine];
2381
+ const base = line.bidi.base;
2382
+ if (!p.from || !natural)
2383
+ return [{ ...p, level: base }];
2384
+ const { para, runs, spans } = p.from;
2385
+ const paragraph = line.bidi.text;
2386
+ // A bidi control takes no room and has no level of its own, whether
2387
+ // `unicode-bidi` stands for it or the document holds it: X9 removes it,
2388
+ // and bidi-js gives it the level of what is before it, which may be
2389
+ // another piece's. It is no end of a piece, nor of a run of one level.
2390
+ const quiet = (at) => spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
2391
+ let lowest = Infinity;
2392
+ let first = -1;
2393
+ let last = -1;
2394
+ for (let at = natural.start; at < natural.end; at += 1) {
2395
+ if (quiet(at))
2396
+ continue;
2397
+ if (first < 0)
2398
+ first = at;
2399
+ last = at;
2400
+ lowest = Math.min(lowest, levelAt(para + at));
2401
+ }
2402
+ if (first < 0)
2403
+ return [{ ...p, level: base }];
2404
+ if (levelAt(para + first) === lowest &&
2405
+ levelAt(para + last) === lowest &&
2406
+ (plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
2407
+ laidAsLevelled(p.from, natural, levelAt, base))) {
2408
+ return [{ ...p, level: lowest }];
2409
+ }
2410
+ const starts = runStarts(runs);
2411
+ const out = [];
2412
+ let at = p.at;
2413
+ let lastRuns = [];
2414
+ /** Where each piece is in the fragment's segment. */
2415
+ const ranges = [];
2416
+ const piece = (from, to, level) => {
2417
+ // its letters, each run's with where it is in the document and whose
2418
+ // text it is
2419
+ const letters = [];
2420
+ let points = 0;
2421
+ for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
2422
+ const runStart = starts[k];
2423
+ if (runStart >= to)
2424
+ break;
2425
+ const a = Math.max(from, runStart);
2426
+ const b = Math.min(to, runStart + runs[k].text.length);
2427
+ if (b <= a || spans.isGap(a))
2428
+ continue;
2429
+ const slice = runs[k].text.slice(a - runStart, b - runStart);
2430
+ letters.push({
2431
+ run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
2432
+ doc: spans.documentAt(a),
2433
+ box: spans.boxAt(a),
2434
+ });
2435
+ points += codeUnitOffsets(slice).length - 1;
2436
+ }
2437
+ if (!letters.length)
2438
+ return;
2439
+ const head = letters[0];
2440
+ const tail = letters[letters.length - 1];
2441
+ const docStart = head.doc;
2442
+ const docEnd = tail.doc + tail.run.text.length;
2443
+ const face = (run, control) => ({
2444
+ text: control,
2445
+ family: run.family,
2446
+ size: run.size,
2447
+ weight: run.weight,
2448
+ style: run.style,
2449
+ });
2450
+ const rtl = (level & 1) === 1;
2451
+ const laidRuns = [face(head.run, rtl ? '\u202e' : '\u202d')];
2452
+ const laidSpans = new SpanMap();
2453
+ laidSpans.add(docStart, 1, head.box, true);
2454
+ for (const { run, doc, box } of letters) {
2455
+ laidSpans.add(doc, run.text.length, box);
2456
+ laidRuns.push(run);
2457
+ }
2458
+ laidRuns.push(face(tail.run, '\u202c'));
2459
+ laidSpans.add(docEnd, 1, tail.box, true);
2460
+ const layout = line.fonts.layout(laidRuns, line.base, {
2461
+ lineHeight: line.lineHeight,
2462
+ align: 'left',
2463
+ direction: rtl ? 'rtl' : 'ltr',
2464
+ fit: PARAGRAPH_FIT,
2465
+ });
2466
+ LAYOUT_RUNS.set(layout, laidRuns);
2467
+ lastRuns = laidRuns;
2468
+ const laid = layout.lines[0];
2469
+ const item = {
2470
+ layout,
2471
+ layoutLine: 0,
2472
+ drawX: text.drawX + (at - p.at),
2473
+ drawY: 0,
2474
+ textStart: docStart,
2475
+ textEnd: docEnd,
2476
+ layoutStart: laid ? laid.start : 0,
2477
+ spans: laidSpans,
2478
+ };
2479
+ laidSpans.giveGaps(item, 0, laidSpans.laidOut);
2480
+ out.push({ kind: 'text', at, item, reads: null, level });
2481
+ ranges.push([from, to]);
2482
+ // the room it takes, its white space at its end with it, which the
2483
+ // engine hangs past the layout's width: from caret to caret
2484
+ const extent = Math.abs(layout.caretPosition(1 + points).x - layout.caretPosition(1).x);
2485
+ at += Math.max(laid?.width ?? 0, extent);
2486
+ };
2487
+ let from = first;
2488
+ let level = levelAt(para + first);
2489
+ let prev = first;
2490
+ for (let o = first + 1; o <= last; o += 1) {
2491
+ if (quiet(o))
2492
+ continue;
2493
+ const l = levelAt(para + o);
2494
+ if (l !== level) {
2495
+ piece(from, prev + 1, level);
2496
+ from = o;
2497
+ level = l;
2498
+ }
2499
+ prev = o;
2500
+ }
2501
+ piece(from, last + 1, level);
2502
+ if (!out.length)
2503
+ return [{ ...p, level: base }];
2504
+ // a word across two of the pieces runs on from one to the next, with no
2505
+ // edge between, as the fragment ran on from the text before it (`JOINS`)
2506
+ const joins = JOINS.get(text);
2507
+ if (joins !== undefined)
2508
+ JOINS.set(out[0].item, joins);
2509
+ for (let k = 1; k < out.length; k += 1) {
2510
+ const before = paragraph[para + ranges[k - 1][1] - 1];
2511
+ const after = paragraph[para + ranges[k][0]];
2512
+ if (!SPACE.test(before) && !SPACE.test(after))
2513
+ JOINS.set(out[k].item, 0);
2514
+ }
2515
+ // the spaces `pre-wrap` keeps that the fragment ended on are its last
2516
+ // piece's, which the engine hangs as it hung them (`hangPreserved`)
2517
+ const tail = out[out.length - 1];
2518
+ if (text.hung) {
2519
+ const rtl = (tail.level & 1) === 1;
2520
+ hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
2521
+ }
2522
+ return out;
2523
+ }
2524
+ /**
2525
+ * Whether the engine, laying a fragment out alone, must have given its
2526
+ * letters the paragraph's levels, without resolving them to see: in a
2527
+ * paragraph of no explicit embedding, a stretch that begins and ends with a
2528
+ * strong letter and pairs no bracket is resolved inside itself, whatever is
2529
+ * around it (W1 to W7, N1 and N2 look no further than the strong letters
2530
+ * either side). The white space it ends on is the paragraph's level to the
2531
+ * engine, whose line ends there (L1), and has to be to the line.
2532
+ */
2533
+ function plainlyLevelled(bidi, from, first, last, levelAt) {
2534
+ if (bidi.explicit)
2535
+ return false;
2536
+ const text = bidi.text;
2537
+ const js = bidiJs();
2538
+ let end = last;
2539
+ while (end > first &&
2540
+ TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))) {
2541
+ if (levelAt(from.para + end) !== bidi.base)
2542
+ return false;
2543
+ end -= 1;
2544
+ }
2545
+ return (STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
2546
+ STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
2547
+ !BRACKETS.test(text.slice(from.para + first, from.para + end + 1)));
2548
+ }
2549
+ /** Whether the engine, laying a fragment out alone, gave its letters the
2550
+ * levels the paragraph gives them: with its line's end white space back
2551
+ * at the paragraph's level (L1), as the engine's own line ends there. */
2552
+ function laidAsLevelled(from, natural, levelAt, base) {
2553
+ const { text, levels } = aloneLevels(from.runs, base === 1);
2554
+ const js = bidiJs();
2555
+ const quiet = (at) => from.spans.isGap(at) || isControl(text.charCodeAt(at));
2556
+ let hung = natural.end;
2557
+ while (hung > natural.start &&
2558
+ TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))) {
2559
+ hung -= 1;
2560
+ }
2561
+ for (let at = natural.start; at < natural.end; at += 1) {
2562
+ if (quiet(at))
2563
+ continue;
2564
+ const alone = at >= hung ? base : levels[at];
2565
+ if (alone !== levelAt(from.para + at))
2566
+ return false;
2567
+ }
2568
+ return true;
2569
+ }
1729
2570
  /** Whether a piece can be out of place in logical order: anything on a
1730
2571
  * right-to-left line, a fragment that reads right to left, or the edge of
1731
2572
  * an element whose direction is not the line's. A line of left-to-right
@@ -1843,7 +2684,14 @@ function atomicRaise(fonts, box, parent) {
1843
2684
  function atomicBaseline(box) {
1844
2685
  const bottom = box.height + box.marginTop + box.marginBottom;
1845
2686
  if (box.kind === 'replaced' ||
1846
- scrolls(box.style) ||
2687
+ // a box that clips sits on its bottom margin edge, for legacy reasons
2688
+ // that stop at block containers (CSS Box Alignment 3, 9.2) — and an
2689
+ // inline table, as browsers keep it: an inline flex box that clips
2690
+ // sits on its first item's, as it does unclipped. A MediaWiki button
2691
+ // is an `overflow: hidden` inline flex box of one icon, and stood its
2692
+ // whole height on the baseline, the line under it the strut's descent
2693
+ // taller
2694
+ (scrolls(box.style) && box.kind !== 'flex') ||
1847
2695
  // layout containment keeps its baseline in (CSS Containment 2, 3.3)
1848
2696
  box.style.contain & CONTAIN_LAYOUT) {
1849
2697
  return bottom;
@@ -1976,7 +2824,11 @@ function childBaseline(child, inside) {
1976
2824
  if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
1977
2825
  return null;
1978
2826
  }
1979
- if (scrolls(child.style)) {
2827
+ // the legacy rule is a block container's last baseline alone (CSS Box
2828
+ // Alignment 3, 9.2): its first is its first line's, clipped or not
2829
+ if (inside === lastBaselineIn &&
2830
+ child.kind !== 'flex' &&
2831
+ scrolls(child.style)) {
1980
2832
  return child.y + child.height + child.marginBottom;
1981
2833
  }
1982
2834
  return inside(child);
@@ -2339,7 +3191,12 @@ function wordSpaced(out, box, run, spacing) {
2339
3191
  run: {
2340
3192
  ...run,
2341
3193
  text: text.slice(from, to),
2342
- ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
3194
+ ...(extra
3195
+ ? {
3196
+ letterSpacing: (run.letterSpacing ?? 0) + extra,
3197
+ kernAcross: true,
3198
+ }
3199
+ : null),
2343
3200
  },
2344
3201
  box,
2345
3202
  length: to - from,
@@ -2385,6 +3242,11 @@ function inlineEdges(box, width, fonts) {
2385
3242
  }
2386
3243
  }
2387
3244
  box.decoration = inlineDecoration(fonts, box, s);
3245
+ if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
3246
+ PADDED_FACES.set(box, faceExtent(fonts, s));
3247
+ }
3248
+ else
3249
+ PADDED_FACES.delete(box);
2388
3250
  const left = box.marginLeft + box.borderLeft + box.padLeft;
2389
3251
  const right = box.padRight + box.borderRight + box.marginRight;
2390
3252
  return s.direction === 'rtl' ? [right, left] : [left, right];
@@ -2833,8 +3695,13 @@ first = null) {
2833
3695
  const holds = i === 0 && first ? first : strut;
2834
3696
  if (!holds)
2835
3697
  continue;
2836
- if (line.baseline < holds.ascent - 0.5 ||
2837
- line.height - line.baseline < holds.descent - 0.5) {
3698
+ // its room as the strut's is worked out, whichever baseline the layout
3699
+ // it is drawn with could keep (`layoutLift`)
3700
+ const text = line.texts.length === 1 ? line.texts[0] : null;
3701
+ const natural = text?.layout.lines[text.layoutLine];
3702
+ const ascent = natural ? lineAscent(natural) : line.baseline;
3703
+ if (ascent < holds.ascent - 0.5 ||
3704
+ line.height - ascent < holds.descent - 0.5) {
2838
3705
  return true;
2839
3706
  }
2840
3707
  }
@@ -3094,11 +3961,15 @@ class SpanMap {
3094
3961
  * a spacer, a bidi control. */
3095
3962
  _gaps = null;
3096
3963
  laidOut = 0;
3097
- add(documentStart, length, box = null) {
3964
+ add(documentStart, length, box = null,
3965
+ /** No text of the document's, though a box's: a bidi control a
3966
+ * level's layout is wrapped in (`levelPieces`), which answers for
3967
+ * the element around it. */
3968
+ gap = box === null) {
3098
3969
  this._laid.push(this.laidOut);
3099
3970
  this._doc.push(documentStart);
3100
3971
  this._boxes.push(box);
3101
- if (!box) {
3972
+ if (gap) {
3102
3973
  this._gaps ??= [];
3103
3974
  for (let i = 0; i < length; i += 1)
3104
3975
  this._gaps.push(this.laidOut + i);
@@ -3115,6 +3986,23 @@ class SpanMap {
3115
3986
  if (gaps.length)
3116
3987
  text.gaps = gaps;
3117
3988
  }
3989
+ /** Whether an offset in the laid-out text is no text of the
3990
+ * document's: a spacer, a bidi control. */
3991
+ isGap(offset) {
3992
+ const gaps = this._gaps;
3993
+ if (!gaps)
3994
+ return false;
3995
+ let lo = 0;
3996
+ let hi = gaps.length;
3997
+ while (lo < hi) {
3998
+ const mid = (lo + hi) >> 1;
3999
+ if (gaps[mid] < offset)
4000
+ lo = mid + 1;
4001
+ else
4002
+ hi = mid;
4003
+ }
4004
+ return gaps[lo] === offset;
4005
+ }
3118
4006
  /** The text box whose text an offset in the laid-out text is. */
3119
4007
  boxAt(offset) {
3120
4008
  const i = this._entryAt(offset);
@@ -3245,6 +4133,165 @@ export function hungSpaces(fonts, run) {
3245
4133
  }
3246
4134
  return hung;
3247
4135
  }
4136
+ /** Where each run starts in their joined text. */
4137
+ function runStarts(runs) {
4138
+ const starts = [];
4139
+ let at = 0;
4140
+ for (const run of runs) {
4141
+ starts.push(at);
4142
+ at += run.text.length;
4143
+ }
4144
+ return starts;
4145
+ }
4146
+ /** The run a code-unit offset in the runs' joined text falls in. */
4147
+ function runIndexAt(starts, offset) {
4148
+ let lo = 0;
4149
+ let hi = starts.length - 1;
4150
+ while (lo < hi) {
4151
+ const mid = (lo + hi + 1) >> 1;
4152
+ if (starts[mid] <= offset)
4153
+ lo = mid;
4154
+ else
4155
+ hi = mid - 1;
4156
+ }
4157
+ return lo;
4158
+ }
4159
+ /**
4160
+ * The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
4161
+ * logical order and each with its advance, and whether a forced break
4162
+ * follows them. An engine strips a line's trailing white space, which is
4163
+ * right for the collapsible spaces CSS removes there, and wrong for these.
4164
+ */
4165
+ function keptAtEnd(natural, runs, starts, spans, fonts) {
4166
+ const boxAt = spans.boxAt;
4167
+ if (!boxAt || !runs.length)
4168
+ return { found: null, forced: false };
4169
+ const charAt = (offset) => {
4170
+ const k = runIndexAt(starts, offset);
4171
+ return runs[k].text.charCodeAt(offset - starts[k]);
4172
+ };
4173
+ let end = natural.end;
4174
+ let forced = false;
4175
+ if (end > natural.start && charAt(end - 1) === 10) {
4176
+ forced = true;
4177
+ end -= 1;
4178
+ }
4179
+ let found = null;
4180
+ let kept = false;
4181
+ for (let at = end - 1; at >= natural.start; at -= 1) {
4182
+ const c = charAt(at);
4183
+ if (isControl(c))
4184
+ continue;
4185
+ if (c !== 0x20)
4186
+ break;
4187
+ // a collapsible space after the last kept one is removed (4.1.3), and
4188
+ // one before a kept one hangs with it
4189
+ const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
4190
+ if (!keeps && !kept)
4191
+ continue;
4192
+ kept = true;
4193
+ const run = runs[runIndexAt(starts, at)];
4194
+ (found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
4195
+ }
4196
+ found?.reverse();
4197
+ return { found, forced };
4198
+ }
4199
+ /**
4200
+ * The spaces `pre-wrap` keeps that a text's line ends on, which the engine
4201
+ * hung (`keptAtEnd`): past the line's end, to its left where the layout
4202
+ * reads right to left, and their inline box's, whose background and border
4203
+ * cover them, as a browser draws them. Noted on the text (`LineText.hung`),
4204
+ * and their advance answered, with whether a forced break follows them.
4205
+ */
4206
+ function hangPreserved(text, runs, starts, fonts, rtl) {
4207
+ const natural = text.layout.lines[text.layoutLine];
4208
+ if (!natural)
4209
+ return { total: 0, forced: false };
4210
+ const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
4211
+ if (!found)
4212
+ return { total: 0, forced };
4213
+ text.hung = found;
4214
+ placeHung(text, rtl);
4215
+ let total = 0;
4216
+ for (const space of found)
4217
+ total += space.width;
4218
+ return { total, forced };
4219
+ }
4220
+ const NOTHING_HUNG = { total: 0, forced: false };
4221
+ /** Put a text's hung spaces beside its line's end, logical order outward:
4222
+ * to the right of its glyphs, or to their left. */
4223
+ function placeHung(text, left) {
4224
+ const natural = text.layout.lines[text.layoutLine];
4225
+ if (!text.hung || !natural)
4226
+ return;
4227
+ let x = left ? natural.x : natural.x + natural.width;
4228
+ for (const space of text.hung) {
4229
+ if (left)
4230
+ x -= space.width;
4231
+ space.x = x;
4232
+ if (!left)
4233
+ x += space.width;
4234
+ }
4235
+ }
4236
+ /**
4237
+ * The runs of a layout with the spaces `pre-wrap` keeps before a forced
4238
+ * break held where they fit (`keptAtEnd`) — a kept newline, or the block's
4239
+ * end after the last line where `ends` — or null where none are. They hang
4240
+ * there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
4241
+ * which the engine gives them when they are handed over as no-break
4242
+ * spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
4243
+ * line with them. Laid out again, the lines break where they did, since
4244
+ * the spaces fit where they are. A line of a layout cannot be moved over
4245
+ * for them afterwards: its lines are drawn in one draw, from one place.
4246
+ */
4247
+ function holdAtBreaks(layout, runs, spans, fonts, width, ends) {
4248
+ const starts = runStarts(runs);
4249
+ let held = null;
4250
+ const lines = layout.lines;
4251
+ for (let i = 0; i < lines.length; i += 1) {
4252
+ const natural = lines[i];
4253
+ const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
4254
+ if (!found || !(forced || (ends && i === lines.length - 1)))
4255
+ continue;
4256
+ let free = width - natural.width;
4257
+ for (const space of found) {
4258
+ if (space.width > free + 0.01)
4259
+ break;
4260
+ free -= space.width;
4261
+ (held ??= []).push(space.at);
4262
+ }
4263
+ }
4264
+ if (!held)
4265
+ return null;
4266
+ held.sort((a, b) => a - b);
4267
+ const out = runs.slice();
4268
+ let next = 0;
4269
+ for (let k = 0; k < runs.length && next < held.length; k += 1) {
4270
+ const start = starts[k];
4271
+ const end = start + runs[k].text.length;
4272
+ if (held[next] >= end)
4273
+ continue;
4274
+ const chars = runs[k].text.split('');
4275
+ while (next < held.length && held[next] < end) {
4276
+ chars[held[next] - start] = '\u00a0';
4277
+ next += 1;
4278
+ }
4279
+ out[k] = { ...runs[k], text: chars.join('') };
4280
+ }
4281
+ return out;
4282
+ }
4283
+ /** The spaces `pre-wrap` keeps that the lines one layout made hung at
4284
+ * their ends (`hangPreserved`), noted on each line's text. */
4285
+ function hangLines(lines, from, runs, fonts, rtl) {
4286
+ let starts = null;
4287
+ for (let i = from; i < lines.length; i += 1) {
4288
+ const text = lines[i].texts[0];
4289
+ if (!text)
4290
+ continue;
4291
+ starts ??= runStarts(runs);
4292
+ hangPreserved(text, runs, starts, fonts, rtl);
4293
+ }
4294
+ }
3248
4295
  /**
3249
4296
  * A clamped paragraph with an ellipsis at the end of its last line (CSS
3250
4297
  * Overflow 4, 4.2): its text up to where that line breaks in the room the
@@ -3471,14 +4518,17 @@ function unwrappedPlacer(style, width, last = true) {
3471
4518
  }
3472
4519
  /**
3473
4520
  * The spacing a space is given to be laid out as one that may be spaced
3474
- * apart, too little to move a glyph. It is not nothing, because neither
3475
- * engine measures a space that is a spaced run of its own as it measures
3476
- * the same space inside its run: ntk shapes each run apart, so the kerning
3477
- * pair a space made with the letter beside it is gone (Arial's `A` and
3478
- * `T` have them), and CoreText spaces a glyph with its kerning attribute,
3479
- * which takes the place of the font's pairs. A line justified from a
3480
- * measure of its spaces as they were came out wider than its box and
3481
- * broke a word early: on macOS, in Helvetica, half of a paragraph did.
4521
+ * apart, too little to move a glyph. It is not nothing, because CoreText
4522
+ * does not measure a space that is a spaced run of its own as it measures
4523
+ * the same space inside its run: it spaces a glyph with its kerning
4524
+ * attribute, which takes the place of the font's pairs. A line justified
4525
+ * from a measure of its spaces as they were came out wider than its box
4526
+ * and broke a word early: on macOS, in Helvetica, half of a paragraph did.
4527
+ * ntk keeps the pairs a spaced space makes with the letters beside it
4528
+ * (Arial's space and `T`, its `L` and space) where the run is marked
4529
+ * `kernAcross` — spacing that justifies a line is in addition to kerning
4530
+ * (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
4531
+ * a browser's is.
3482
4532
  */
3483
4533
  const HAIR = 1e-6;
3484
4534
  /** The runs with each space a run of its own, spaced a hair apart. */
@@ -3497,6 +4547,7 @@ function spacedApart(runs) {
3497
4547
  ...run,
3498
4548
  text: text[at],
3499
4549
  letterSpacing: (run.letterSpacing ?? 0) + HAIR,
4550
+ kernAcross: true,
3500
4551
  });
3501
4552
  piece = at + 1;
3502
4553
  }
@@ -3571,6 +4622,7 @@ function widenedAt(runs, extra) {
3571
4622
  ...run,
3572
4623
  text: text[at],
3573
4624
  letterSpacing: (run.letterSpacing ?? 0) + add,
4625
+ kernAcross: true,
3574
4626
  });
3575
4627
  piece = at + 1;
3576
4628
  }
@@ -3817,8 +4869,8 @@ function naturalLineHeight(fonts, style) {
3817
4869
  }
3818
4870
  /**
3819
4871
  * A block's strut: the ascent and descent of a line of nothing in its face,
3820
- * the half-leading its `line-height` adds shared above and below, as the
3821
- * text engines set a line (CSS 2.1 10.8.1).
4872
+ * the leading its `line-height` adds shared above and below as
4873
+ * `ascentOnLine` shares it (CSS 2.1 10.8.1).
3822
4874
  */
3823
4875
  export function strutOf(fonts, style) {
3824
4876
  // text set at no size takes no room, and CoreText reads a size of 0 as
@@ -3831,8 +4883,57 @@ export function strutOf(fonts, style) {
3831
4883
  : style.lineHeightIsLength
3832
4884
  ? style.lineHeight
3833
4885
  : style.lineHeight * style.fontSize;
3834
- const half = (target - face.ascent - face.descent) / 2;
3835
- return { ascent: face.ascent + half, descent: face.descent + half };
4886
+ const ascent = ascentOnLine(face.ascent, face.descent, target);
4887
+ return { ascent, descent: target - ascent };
4888
+ }
4889
+ /**
4890
+ * How far above its baseline an inline box's room on a line reaches: its
4891
+ * face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
4892
+ * rounded down to a whole pixel, the rest of the leading going below.
4893
+ *
4894
+ * The spec's halves are exact, and where one is not a whole pixel which
4895
+ * side of the grid the text lands on is the user agent's. Blink rounds the
4896
+ * half above down (`CalculateLeadingSpace`, core/layout/inline/
4897
+ * line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
4898
+ * 15 and 2px under. Split evenly, that box reached half a pixel above the
4899
+ * 12px strut beside it, whose 4px of leading divides, and every such line
4900
+ * came out 18.5px tall.
4901
+ */
4902
+ export function ascentOnLine(ascent, descent, height) {
4903
+ return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
4904
+ }
4905
+ /**
4906
+ * How far above its baseline a text engine's line reaches, its leading
4907
+ * shared as `ascentOnLine` shares it. The engines split it evenly (the
4908
+ * contract every text engine owes, a baseline `(height - ascent -
4909
+ * descent) / 2 + ascent` down), so a line on its own sits up to a pixel
4910
+ * lower than a browser's; one that did not say how tall its face is keeps
4911
+ * the engine's.
4912
+ */
4913
+ function lineAscent(natural) {
4914
+ const { ascent, descent } = natural;
4915
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
4916
+ return natural.baseline - natural.y;
4917
+ }
4918
+ return ascentOnLine(ascent, descent, natural.height);
4919
+ }
4920
+ /**
4921
+ * How far up a layout drawn whole moves for each of its lines' baselines
4922
+ * to be where `lineAscent` puts it. A layout is drawn in one batch, from
4923
+ * one origin, so it moves by one amount or not at all: where its lines
4924
+ * would move by different amounts — a face of other metrics on one — it
4925
+ * keeps the engine's baselines, which are less than a pixel out.
4926
+ */
4927
+ function layoutLift(layout) {
4928
+ let lift = NaN;
4929
+ for (const natural of layout.lines) {
4930
+ const own = natural.baseline - natural.y - lineAscent(natural);
4931
+ if (Number.isNaN(lift))
4932
+ lift = own;
4933
+ else if (Math.abs(own - lift) > 1e-6)
4934
+ return 0;
4935
+ }
4936
+ return Number.isNaN(lift) ? 0 : lift;
3836
4937
  }
3837
4938
  /**
3838
4939
  * The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
@@ -4008,12 +5109,18 @@ const NO_LIFT = { raise: 0, edge: null, lead: false };
4008
5109
  * and size is past at once, and any other answer is kept on its style.
4009
5110
  */
4010
5111
  function ownsLeading(fonts, block, style) {
4011
- // a bold or an italic face keeps its family's line metrics, and a
4012
- // paragraph is thick with <strong>, <em> and <a>
5112
+ // the block's own face is past at once. A bold or an italic one is not:
5113
+ // a paragraph is thick with <strong>, <em> and <a>, and most families'
5114
+ // faces share their line metrics, but not every one's — Helvetica Neue
5115
+ // Bold reaches 0.975em above its baseline to the regular's 0.952, and
5116
+ // Blink, which rounds each to a whole pixel, sets a 12px bold word on a
5117
+ // 19.2px line a pixel taller. It is measured once for its style, below.
4013
5118
  if (style.lineHeight === block.lineHeight &&
4014
5119
  style.lineHeightIsLength === block.lineHeightIsLength &&
4015
5120
  style.fontSize === block.fontSize &&
4016
- style.fontFamily === block.fontFamily) {
5121
+ style.fontFamily === block.fontFamily &&
5122
+ style.fontWeight === block.fontWeight &&
5123
+ style.fontStyle === block.fontStyle) {
4017
5124
  return false;
4018
5125
  }
4019
5126
  const known = LEADS.get(style);
@@ -4126,7 +5233,7 @@ function tooNarrow(runs, end, width, room) {
4126
5233
  }
4127
5234
  if (WORD_CHAR.test(before) && WORD_CHAR.test(after))
4128
5235
  return true;
4129
- return width > room + 0.5 && before !== '';
5236
+ return width > room + FIT_SLACK && before !== '';
4130
5237
  }
4131
5238
  const WORD_CHAR = /^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
4132
5239
  function bandAt(options, y, height) {