@react-x11/components 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +38 -10
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +43 -58
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts +134 -20
  10. package/dist/html/css/cascade.d.ts.map +1 -1
  11. package/dist/html/css/cascade.js +538 -128
  12. package/dist/html/css/cascade.js.map +1 -1
  13. package/dist/html/css/color.d.ts +19 -0
  14. package/dist/html/css/color.d.ts.map +1 -1
  15. package/dist/html/css/color.js +86 -0
  16. package/dist/html/css/color.js.map +1 -1
  17. package/dist/html/css/parse.d.ts +40 -2
  18. package/dist/html/css/parse.d.ts.map +1 -1
  19. package/dist/html/css/parse.js +391 -13
  20. package/dist/html/css/parse.js.map +1 -1
  21. package/dist/html/css/style.d.ts +52 -1
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +328 -11
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -7
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +10 -1
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js +27 -2
  32. package/dist/html/css/values.js.map +1 -1
  33. package/dist/html/css/vars.d.ts +5 -0
  34. package/dist/html/css/vars.d.ts.map +1 -1
  35. package/dist/html/css/vars.js +2 -2
  36. package/dist/html/css/vars.js.map +1 -1
  37. package/dist/html/dom.d.ts.map +1 -1
  38. package/dist/html/dom.js +38 -3
  39. package/dist/html/dom.js.map +1 -1
  40. package/dist/html/fonts.d.ts +25 -1
  41. package/dist/html/fonts.d.ts.map +1 -1
  42. package/dist/html/fonts.js +99 -6
  43. package/dist/html/fonts.js.map +1 -1
  44. package/dist/html/form.d.ts +200 -0
  45. package/dist/html/form.d.ts.map +1 -0
  46. package/dist/html/form.js +801 -0
  47. package/dist/html/form.js.map +1 -0
  48. package/dist/html/index.d.ts +12 -0
  49. package/dist/html/index.d.ts.map +1 -1
  50. package/dist/html/index.js +30 -264
  51. package/dist/html/index.js.map +1 -1
  52. package/dist/html/layout/block.d.ts +11 -2
  53. package/dist/html/layout/block.d.ts.map +1 -1
  54. package/dist/html/layout/block.js +186 -19
  55. package/dist/html/layout/block.js.map +1 -1
  56. package/dist/html/layout/boxes.d.ts +65 -7
  57. package/dist/html/layout/boxes.d.ts.map +1 -1
  58. package/dist/html/layout/boxes.js +87 -22
  59. package/dist/html/layout/boxes.js.map +1 -1
  60. package/dist/html/layout/cache.d.ts +2 -2
  61. package/dist/html/layout/cache.d.ts.map +1 -1
  62. package/dist/html/layout/cache.js +8 -3
  63. package/dist/html/layout/cache.js.map +1 -1
  64. package/dist/html/layout/flex.js +4 -1
  65. package/dist/html/layout/flex.js.map +1 -1
  66. package/dist/html/layout/floats.d.ts +4 -0
  67. package/dist/html/layout/floats.d.ts.map +1 -1
  68. package/dist/html/layout/floats.js +26 -0
  69. package/dist/html/layout/floats.js.map +1 -1
  70. package/dist/html/layout/inline.d.ts +26 -2
  71. package/dist/html/layout/inline.d.ts.map +1 -1
  72. package/dist/html/layout/inline.js +1232 -115
  73. package/dist/html/layout/inline.js.map +1 -1
  74. package/dist/html/node.d.ts +120 -15
  75. package/dist/html/node.d.ts.map +1 -1
  76. package/dist/html/node.js +993 -174
  77. package/dist/html/node.js.map +1 -1
  78. package/dist/html/paint.d.ts +75 -2
  79. package/dist/html/paint.d.ts.map +1 -1
  80. package/dist/html/paint.js +836 -200
  81. package/dist/html/paint.js.map +1 -1
  82. package/dist/html/surfaces.d.ts +7 -2
  83. package/dist/html/surfaces.d.ts.map +1 -1
  84. package/dist/html/surfaces.js +16 -0
  85. package/dist/html/surfaces.js.map +1 -1
  86. package/dist/html/widgets.d.ts +29 -0
  87. package/dist/html/widgets.d.ts.map +1 -0
  88. package/dist/html/widgets.js +620 -0
  89. package/dist/html/widgets.js.map +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal/window.d.ts +22 -0
  94. package/dist/internal/window.d.ts.map +1 -1
  95. package/dist/internal/window.js +35 -8
  96. package/dist/internal/window.js.map +1 -1
  97. package/dist/richtext/node.d.ts +7 -0
  98. package/dist/richtext/node.d.ts.map +1 -1
  99. package/dist/richtext/node.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/flow/node.ts +80 -64
  102. package/src/html/controls.ts +79 -58
  103. package/src/html/css/cascade.ts +610 -146
  104. package/src/html/css/color.ts +87 -0
  105. package/src/html/css/parse.ts +431 -13
  106. package/src/html/css/style.ts +367 -11
  107. package/src/html/css/ua.ts +76 -8
  108. package/src/html/css/values.ts +31 -3
  109. package/src/html/css/vars.ts +2 -2
  110. package/src/html/dom.ts +41 -3
  111. package/src/html/fonts.ts +116 -6
  112. package/src/html/form.ts +962 -0
  113. package/src/html/index.ts +49 -300
  114. package/src/html/layout/block.ts +231 -16
  115. package/src/html/layout/boxes.ts +133 -32
  116. package/src/html/layout/cache.ts +8 -3
  117. package/src/html/layout/flex.ts +4 -1
  118. package/src/html/layout/floats.ts +24 -0
  119. package/src/html/layout/inline.ts +1445 -108
  120. package/src/html/node.ts +1010 -166
  121. package/src/html/paint.ts +980 -270
  122. package/src/html/surfaces.ts +21 -1
  123. package/src/html/widgets.ts +735 -0
  124. package/src/index.ts +1 -0
  125. package/src/internal/window.ts +36 -8
  126. package/src/richtext/node.ts +7 -0
@@ -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,12 +629,22 @@ 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();
577
636
  return;
578
637
  }
638
+ // A line with no text on it — a control, an image or an inline-block
639
+ // that wrapped on its own — stands where the text around it does: an
640
+ // empty range at the end of the line before, not at 0. What finds a
641
+ // line by its text binary-searches these ranges as sorted
642
+ // (`lineBands`, `caretAt`), and a textless line at 0 after a line of
643
+ // text sent the search past every line: a `<label>` on the line
644
+ // before a submit button that wrapped measured as no box at all.
645
+ if (!line.texts.length && lines.length) {
646
+ line.textStart = line.textEnd = lines[lines.length - 1].textEnd;
647
+ }
579
648
  lines.push(line);
580
649
  widest = Math.max(widest, line.width);
581
650
  y += line.height;
@@ -690,7 +759,12 @@ function linesOf(block, options, items, floatCount) {
690
759
  if (raise)
691
760
  placed.raise = raise;
692
761
  open.atomics.push(placed);
693
- open.order.push({ kind: 'atomic', at: open.x, item: placed });
762
+ open.order.push({
763
+ kind: 'atomic',
764
+ at: open.x,
765
+ item: placed,
766
+ ...(paraBidi ? { para: paraBidi.starts[index] } : null),
767
+ });
694
768
  open.x += outer;
695
769
  open.hang = 0;
696
770
  open.left = room.left;
@@ -731,7 +805,10 @@ function linesOf(block, options, items, floatCount) {
731
805
  !open.edges.length &&
732
806
  !pending.length &&
733
807
  !deferred.length &&
734
- !(options.floats?.intersects(options.startY + y, Infinity) ?? false);
808
+ !(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
809
+ // and ordered alone as the paragraph orders it
810
+ (!paraBidi ||
811
+ orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
735
812
  if (tailIsPlain) {
736
813
  const tailOptions = {
737
814
  maxWidth: wraps(style) ? Math.max(1, available) : undefined,
@@ -739,20 +816,31 @@ function linesOf(block, options, items, floatCount) {
739
816
  align,
740
817
  direction: style.direction,
741
818
  overflowWrap: wrapWords,
819
+ fit: PARAGRAPH_FIT,
742
820
  };
743
- let layout = fonts.layout(segment.runs, base, tailOptions);
744
- LAYOUT_RUNS.set(layout, segment.runs);
821
+ let tailRuns = segment.runs;
822
+ let layout = fonts.layout(tailRuns, base, tailOptions);
823
+ LAYOUT_RUNS.set(layout, tailRuns);
824
+ const held = keepsSpaces
825
+ ? holdAtBreaks(layout, tailRuns, segment.spans, fonts, tailOptions.maxWidth ?? options.width, true)
826
+ : null;
827
+ if (held) {
828
+ tailRuns = held;
829
+ layout = fonts.layout(tailRuns, base, tailOptions);
830
+ LAYOUT_RUNS.set(layout, tailRuns);
831
+ }
745
832
  // the lines past the floats are justified as the lines beside them
746
833
  // were (`finishLine`), in the room they have
747
- layout = justifiedLayout(fonts, segment.runs, base, tailOptions, layout, Math.max(1, available), justify).layout;
834
+ layout = justifiedLayout(fonts, tailRuns, base, tailOptions, layout, Math.max(1, available), justify).layout;
748
835
  const tail = [];
836
+ const lift = layoutLift(layout);
749
837
  for (let i = 0; i < layout.lines.length; i += 1) {
750
838
  const natural = layout.lines[i];
751
839
  const text = {
752
840
  layout,
753
841
  layoutLine: i,
754
842
  drawX: band.left,
755
- drawY: y,
843
+ drawY: y - lift,
756
844
  textStart: segment.spans.documentAt(natural.start),
757
845
  textEnd: segment.spans.documentAt(natural.end),
758
846
  layoutStart: natural.start,
@@ -764,13 +852,15 @@ function linesOf(block, options, items, floatCount) {
764
852
  y: y + natural.y,
765
853
  width: natural.width,
766
854
  height: natural.height,
767
- baseline: natural.baseline - natural.y,
855
+ baseline: natural.baseline - natural.y - lift,
768
856
  texts: [text],
769
857
  textStart: text.textStart,
770
858
  textEnd: text.textEnd,
771
859
  atomics: [],
772
860
  });
773
861
  }
862
+ if (keepsSpaces)
863
+ hangLines(tail, 0, tailRuns, fonts, rtl);
774
864
  // or a line at a time, where one of them is shorter than the strut
775
865
  if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
776
866
  for (const line of tail) {
@@ -801,27 +891,72 @@ function linesOf(block, options, items, floatCount) {
801
891
  !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
802
892
  ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
803
893
  : 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];
894
+ const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
895
+ let fragment = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, room - tied);
896
+ let first = fragment.lines[0];
820
897
  if (!first) {
821
898
  index = segment.nextIndex;
822
899
  offset = 0;
823
900
  continue;
824
901
  }
902
+ // Did the segment wrap? ntk answers with `truncated`. A layout that does
903
+ // not carry the flag — react-x11's Cocoa engine reports none — is asked
904
+ // the same of its line ends instead: it wrapped if anything but
905
+ // whitespace follows the first line. Read as "fitted", a cut fragment
906
+ // advanced past the whole segment, and a paragraph beside a float lost
907
+ // every line after its first. And a line that ends at a `<br>` is over
908
+ // whether or not anything followed it in this segment: what comes next
909
+ // — an atomic, an element's edge — is the next line's.
910
+ let wrapped = breakBefore(segment.runs, first.end) ||
911
+ (fragment.truncated ?? inkBeyond(segment.runs, first.end));
912
+ // An inline box's edge is no place to break a line (CSS Text 3, 5.1):
913
+ // where the segment runs to its end on this line, its last word runs on
914
+ // past it, through the edges after it, into the text they stand before
915
+ // — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
916
+ // without the padding. Where the line may break at that text after all,
917
+ // the edges that close boxes are still the word's, as a box's end goes
918
+ // with the content it closes, and it is the ones that open a box that
919
+ // go with what follows. The line holds the word and all of that or none
920
+ // of it, so where it does not fit, the line breaks at the last place in
921
+ // the segment it may break, and the word goes to the next line whole.
922
+ // Where the segment has no such place, the word is the line's first,
923
+ // and is measured with what it runs into (below). A word that ends in
924
+ // white space hangs it, and its edges after it with it.
925
+ let glued = 0;
926
+ if (!wrapped &&
927
+ wraps(style) &&
928
+ lineItems[segment.nextIndex]?.kind === 'edge' &&
929
+ !BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
930
+ // none of it matters where the most it could come to fits: most
931
+ // edges are nowhere near a line's end
932
+ first.width + unbreakableBound(lineItems, segment.nextIndex) >
933
+ room + FIT_SLACK) {
934
+ const after = textAfterEdges(lineItems, segment.nextIndex);
935
+ glued =
936
+ after &&
937
+ !breaksBetween(fonts, lineBase, segment.runs, runsLength(segment.runs), after, breaksAnywhere)
938
+ ? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
939
+ : closingEdges(lineItems, segment.nextIndex);
940
+ }
941
+ if (glued > 0 && first.width + glued > room + FIT_SLACK) {
942
+ // the last place it may break: laid out a hair narrower than it is,
943
+ // which the rest of the segment fits, not in the room the word
944
+ // leaves, where it broke after its first word if the word and what
945
+ // it runs into are wider than the line
946
+ const earlier = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, first.width - 0.5);
947
+ const line = earlier.lines[0];
948
+ // and not inside a word that `overflow-wrap` cut to fit: that is for
949
+ // a word no line holds, and the word and what it runs into may fit
950
+ // the next line
951
+ if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
952
+ fragment = earlier;
953
+ first = line;
954
+ wrapped = true;
955
+ glued = 0;
956
+ }
957
+ }
958
+ // the first word, with what it runs into past the segment
959
+ const word = first.width + glued;
825
960
  // Nothing fits beside the floats — the first word is wider than the
826
961
  // room they leave, and was either run past it or cut inside itself to
827
962
  // fit — so the line moves down to where a float ends, and tries again
@@ -830,7 +965,7 @@ function linesOf(block, options, items, floatCount) {
830
965
  // is `overflow-wrap`'s to break or to let run past.
831
966
  if (isEmpty(open) &&
832
967
  available < options.width &&
833
- tooNarrow(segment.runs, first.end, first.width, room)) {
968
+ tooNarrow(segment.runs, first.end, word, room)) {
834
969
  const below = belowFloats(options, y, guess);
835
970
  if (below !== null) {
836
971
  y = below;
@@ -839,27 +974,37 @@ function linesOf(block, options, items, floatCount) {
839
974
  }
840
975
  // An inline box's opening edge goes with the first word after it: where
841
976
  // 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.
977
+ // line, rather than the edge being left at this one's end — where the
978
+ // line may break there at all. The edge is no place to break (CSS Text
979
+ // 3, 5.1), so that is where the line may break between what it ends on
980
+ // and the word: after white space or an atomic, or where the text
981
+ // engine breaks the two run together (`breaksBetween`). `ab<span
982
+ // style="padding: 0 4px">cd</span>` is one word, and a line too narrow
983
+ // for it runs past its end, as it does for the word without the
984
+ // padding; broken before the span, a float's min-content width was a
985
+ // letter wide, and it came out as wide as its room.
986
+ // And a word that may start a line — after a space the line ends on,
987
+ // or after an atomic, which has a break after it (CSS Text 3, 5.1) —
988
+ // goes to the next line whole where it does not fit what is left of
989
+ // this one, rather than being broken inside itself to fit it: the text
990
+ // after an inline-block, or after a float it was cut at, was its first
991
+ // letter at the line's end and the rest on the next.
849
992
  const startsLine = wraps(style) &&
850
993
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
851
994
  if (!isEmpty(open) &&
852
995
  (pendingWidth > 0
853
- ? tooNarrow(segment.runs, first.end, first.width, room)
996
+ ? tooNarrow(segment.runs, first.end, word, room) &&
997
+ breaksAfterLine(open, style, fonts, lineBase, segment.runs, breaksAnywhere)
854
998
  : startsLine &&
855
999
  // run past the room — its white space is no part of the width —
856
1000
  // or cut inside itself to fit it
857
- (first.width > room + 0.5 ||
858
- tooNarrow(segment.runs, first.end, first.width, room)))) {
1001
+ (word > room + FIT_SLACK ||
1002
+ tooNarrow(segment.runs, first.end, word, room)))) {
859
1003
  close();
860
1004
  continue;
861
1005
  }
862
1006
  placePending(band.left);
1007
+ const joins = joinsOnto(open, segment.runs, first.start);
863
1008
  const placed = {
864
1009
  layout: fragment,
865
1010
  layoutLine: 0,
@@ -872,26 +1017,35 @@ function linesOf(block, options, items, floatCount) {
872
1017
  spans: segment.spans,
873
1018
  };
874
1019
  segment.spans.giveGaps(placed, first.start, first.end);
1020
+ if (joins !== undefined)
1021
+ JOINS.set(placed, joins);
875
1022
  open.texts.push(placed);
876
1023
  open.order.push({
877
1024
  kind: 'text',
878
1025
  at: open.x,
879
1026
  item: placed,
880
1027
  reads: readingOf(segment.runs, first.start, first.end),
1028
+ ...(paraBidi
1029
+ ? {
1030
+ from: {
1031
+ para: paraBidi.starts[index] + offset,
1032
+ runs: segment.runs,
1033
+ spans: segment.spans,
1034
+ },
1035
+ }
1036
+ : null),
881
1037
  });
882
1038
  open.x += first.width;
883
1039
  open.left = band.left;
884
1040
  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));
1041
+ // the spaces `pre-wrap` keeps that it ends on, which the engine hung
1042
+ const hung = keepsSpaces
1043
+ ? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
1044
+ : NOTHING_HUNG;
1045
+ /** Before a forced break, the ones that fit take room (4.1.3). */
1046
+ const holdHung = () => {
1047
+ open.x += Math.min(hung.total, Math.max(0, available - open.x));
1048
+ };
895
1049
  if (!wrapped) {
896
1050
  // It fitted: the cursor stays on this line for whatever comes next.
897
1051
  //
@@ -905,7 +1059,11 @@ function linesOf(block, options, items, floatCount) {
905
1059
  const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
906
1060
  let trailing = '';
907
1061
  for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
908
- const text = segment.runs[i].text;
1062
+ // through the bidi controls after them, which take no room: the
1063
+ // engine strips the spaces before one as well
1064
+ const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
1065
+ if (!text)
1066
+ continue;
909
1067
  const m = hung.exec(text);
910
1068
  if (!m)
911
1069
  break;
@@ -915,18 +1073,29 @@ function linesOf(block, options, items, floatCount) {
915
1073
  }
916
1074
  if (trailing) {
917
1075
  // 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)
1076
+ // these spaces end this one, where CSS removes them (16.6.1).
1077
+ // As wide as the engine says they make its line where the line
1078
+ // goes on, which a layout that fits as a browser does rounds with
1079
+ // the text they end, once (`advance`): the text rounded up to a
1080
+ // 64th and the spaces added after it came to more
919
1081
  open.hang =
920
- spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
921
- trailing.length;
1082
+ first.advance !== undefined
1083
+ ? first.advance - first.width
1084
+ : spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
1085
+ trailing.length;
922
1086
  open.x += open.hang;
923
1087
  }
924
1088
  }
1089
+ else if (hung.total)
1090
+ holdHung();
925
1091
  index = segment.nextIndex;
926
1092
  offset = 0;
927
1093
  continue;
928
1094
  }
929
- close(breakBefore(segment.runs, first.end));
1095
+ const forced = breakBefore(segment.runs, first.end);
1096
+ if (forced && hung.total)
1097
+ holdHung();
1098
+ close(forced);
930
1099
  const advanced = advance(items, index, offset, first.end);
931
1100
  index = advanced.index;
932
1101
  offset = advanced.offset;
@@ -943,6 +1112,27 @@ function linesOf(block, options, items, floatCount) {
943
1112
  return { lines, height: y, width: widest, floatRow };
944
1113
  }
945
1114
  const EMPTY = { lines: [], height: 0, width: 0 };
1115
+ /** The first line of a segment's text in a width, as the line at a time
1116
+ * lays one out (`linesOf`). */
1117
+ function fragmentLayout(fonts, runs, base, style, lineHeight, overflowWrap, width) {
1118
+ const laid = fonts.layout(runs, base, {
1119
+ maxWidth: wraps(style) ? Math.max(1, width) : undefined,
1120
+ lineHeight,
1121
+ // Never aligned by the text layout: the alignment belongs to the whole
1122
+ // line — its text, its atomics and its inline boxes' edges together —
1123
+ // which only this loop can see, and `finishLine` shifts it all. Laid
1124
+ // out aligned, the first fragment of a centred line was centred alone
1125
+ // and whatever followed it on the line was placed as though it had not
1126
+ // been, over it.
1127
+ align: 'left',
1128
+ direction: style.direction,
1129
+ overflowWrap,
1130
+ fit: PARAGRAPH_FIT,
1131
+ maxLines: 1,
1132
+ });
1133
+ LAYOUT_RUNS.set(laid, runs);
1134
+ return laid;
1135
+ }
946
1136
  /**
947
1137
  * Which line each float a paragraph placed stands beside: the one whose top
948
1138
  * it went at, whose content moved over for it — added up on the line, for
@@ -1050,9 +1240,9 @@ function spacersHold(style, items) {
1050
1240
  * (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
1051
1241
  * keep it there.
1052
1242
  */
1053
- function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1243
+ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts, keepsSpaces) {
1054
1244
  const wrapWords = overflowWrapOf(style, items);
1055
- const runs = [];
1245
+ let runs = [];
1056
1246
  const spans = new SpanMap();
1057
1247
  const spacers = [];
1058
1248
  let doc = 0;
@@ -1081,12 +1271,22 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1081
1271
  align,
1082
1272
  direction: style.direction,
1083
1273
  overflowWrap: wrapWords,
1274
+ fit: PARAGRAPH_FIT,
1084
1275
  };
1085
1276
  let layout = fonts.layout(runs, base, layoutOptions);
1086
1277
  LAYOUT_RUNS.set(layout, runs);
1278
+ const held = keepsSpaces
1279
+ ? holdAtBreaks(layout, runs, spans, fonts, width, true)
1280
+ : null;
1281
+ if (held) {
1282
+ runs = held;
1283
+ layout = fonts.layout(runs, base, layoutOptions);
1284
+ LAYOUT_RUNS.set(layout, runs);
1285
+ }
1087
1286
  // justified with its spacers in it, which are edges and not spaces
1088
1287
  layout = justifiedLayout(fonts, runs, base, layoutOptions, layout, width, justification(style), new Set(spacers.map((spacer) => spacer.at))).layout;
1089
1288
  const place = wraps(style) ? null : unwrappedPlacer(style, width);
1289
+ const lift = layoutLift(layout);
1090
1290
  let offsets = null;
1091
1291
  const lines = [];
1092
1292
  let widest = 0;
@@ -1117,7 +1317,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1117
1317
  layout,
1118
1318
  layoutLine: i,
1119
1319
  drawX: dx,
1120
- drawY: 0,
1320
+ drawY: -lift,
1121
1321
  textStart: spans.documentAt(natural.start),
1122
1322
  textEnd: spans.documentAt(natural.end),
1123
1323
  layoutStart: natural.start,
@@ -1129,7 +1329,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1129
1329
  y: natural.y,
1130
1330
  width: natural.width,
1131
1331
  height: natural.height,
1132
- baseline: natural.baseline - natural.y,
1332
+ baseline: natural.baseline - natural.y - lift,
1133
1333
  texts: [text],
1134
1334
  textStart: text.textStart,
1135
1335
  textEnd: text.textEnd,
@@ -1138,6 +1338,8 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
1138
1338
  });
1139
1339
  widest = Math.max(widest, natural.width);
1140
1340
  }
1341
+ if (keepsSpaces)
1342
+ hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
1141
1343
  return { lines, height: layout.height, width: widest };
1142
1344
  }
1143
1345
  /** The code point a code-unit offset into a layout's text starts. */
@@ -1182,6 +1384,7 @@ export function layoutOffsetOf(text, doc, end = false) {
1182
1384
  * `place` says; returns the widest. */
1183
1385
  function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1184
1386
  let widest = 0;
1387
+ const lift = layoutLift(layout);
1185
1388
  for (let i = 0; i < layout.lines.length; i += 1) {
1186
1389
  const natural = layout.lines[i];
1187
1390
  const dx = place ? place(natural) : 0;
@@ -1189,7 +1392,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1189
1392
  layout,
1190
1393
  layoutLine: i,
1191
1394
  drawX: xOff + dx,
1192
- drawY: yOff,
1395
+ drawY: yOff - lift,
1193
1396
  textStart: spans.documentAt(natural.start),
1194
1397
  textEnd: spans.documentAt(natural.end),
1195
1398
  layoutStart: natural.start,
@@ -1201,7 +1404,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
1201
1404
  y: yOff + natural.y,
1202
1405
  width: natural.width,
1203
1406
  height: natural.height,
1204
- baseline: natural.baseline - natural.y,
1407
+ baseline: natural.baseline - natural.y - lift,
1205
1408
  texts: [text],
1206
1409
  textStart: text.textStart,
1207
1410
  textEnd: text.textEnd,
@@ -1237,7 +1440,7 @@ const CHUNKS = {
1237
1440
  * breaking never crosses a required break), so the chunk seams are
1238
1441
  * invisible; each chunk stays a single batch a viewport can keep whole.
1239
1442
  */
1240
- function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS) {
1443
+ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS, keepsSpaces = false) {
1241
1444
  const wrapWords = overflowWrapOf(style, items);
1242
1445
  const lines = [];
1243
1446
  let widest = 0;
@@ -1248,7 +1451,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1248
1451
  let hardLines = 0;
1249
1452
  const { cut } = chunking;
1250
1453
  const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
1251
- const flush = () => {
1454
+ const flush = (last = false) => {
1252
1455
  if (!chunkRuns.length)
1253
1456
  return;
1254
1457
  const chunkOptions = {
@@ -1257,14 +1460,27 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1257
1460
  align,
1258
1461
  direction: style.direction,
1259
1462
  overflowWrap: cutWrap(style, cut, wrapWords),
1463
+ fit: PARAGRAPH_FIT,
1260
1464
  ...cut,
1261
1465
  };
1262
1466
  let layout = fonts.layout(chunkRuns, base, chunkOptions);
1263
1467
  LAYOUT_RUNS.set(layout, chunkRuns);
1468
+ const held = keepsSpaces
1469
+ ? holdAtBreaks(layout, chunkRuns, chunkSpans, fonts, chunkOptions.maxWidth ?? width, last && !cut)
1470
+ : null;
1471
+ if (held) {
1472
+ chunkRuns = held;
1473
+ layout = fonts.layout(chunkRuns, base, chunkOptions);
1474
+ LAYOUT_RUNS.set(layout, chunkRuns);
1475
+ }
1264
1476
  // a chunk ends at a forced break or at the paragraph's end, which is
1265
1477
  // where `justifiedRuns` takes a chunk's last line to end
1266
1478
  layout = justifiedLayout(fonts, chunkRuns, base, chunkOptions, layout, width, justification(style)).layout;
1479
+ const from = lines.length;
1267
1480
  widest = Math.max(widest, emitLayout(layout, chunkSpans, 0, y, lines, place));
1481
+ if (keepsSpaces) {
1482
+ hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
1483
+ }
1268
1484
  y += layout.height;
1269
1485
  chunkRuns = [];
1270
1486
  chunkSpans = new SpanMap();
@@ -1324,7 +1540,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
1324
1540
  text = text.slice(cut);
1325
1541
  }
1326
1542
  }
1327
- flush();
1543
+ flush(true);
1328
1544
  return { lines, height: y, width: widest };
1329
1545
  }
1330
1546
  function openLine(indent) {
@@ -1345,6 +1561,250 @@ function breaksAtEnd(open, style) {
1345
1561
  return (wraps(style) &&
1346
1562
  (open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic'));
1347
1563
  }
1564
+ /**
1565
+ * Whether a line may break before an inline box's opening edge, between
1566
+ * what the line has got to and `next`, the text after the edge: after an
1567
+ * atomic, or after the white space the line ends on, as `breaksAtEnd`
1568
+ * says, or where the text engine breaks the line's last text and `next`
1569
+ * run together (`breaksBetween`). An edge on the way is no place to break
1570
+ * of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
1571
+ * yet has none to hold on to.
1572
+ */
1573
+ function breaksAfterLine(open, style, fonts, base, next, anywhere) {
1574
+ if (!wraps(style))
1575
+ return false;
1576
+ if (open.hang > 0)
1577
+ return true;
1578
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1579
+ const placed = open.order[i];
1580
+ if (placed.kind === 'edge')
1581
+ continue;
1582
+ if (placed.kind === 'atomic')
1583
+ return true;
1584
+ const text = placed.item;
1585
+ const runs = LAYOUT_RUNS.get(text.layout);
1586
+ if (!runs || (runs.length > 0 && typeof runs[0] === 'number'))
1587
+ return true;
1588
+ const end = text.layout.lines[text.layoutLine].end;
1589
+ return breaksBetween(fonts, base, runs, end, next, anywhere);
1590
+ }
1591
+ return true;
1592
+ }
1593
+ /**
1594
+ * How wide the edges are between the text the open line ends on and text
1595
+ * starting at `start` in `runs`, where the one runs on into the other
1596
+ * (`JOINS`): neither is white space at the join, and no atomic, which has a
1597
+ * break before and after it, is between them. Undefined where they do not
1598
+ * join, or the line has no text yet. Letters on either side are taken to
1599
+ * join whatever they are, which may count an ideograph's join that the
1600
+ * engine would break at: it makes the bound it is for a larger one, never a
1601
+ * smaller.
1602
+ */
1603
+ function joinsOnto(open, runs, start) {
1604
+ if (open.hang > 0)
1605
+ return undefined;
1606
+ const next = charAt(runs, start);
1607
+ if (next === '' || SPACE.test(next))
1608
+ return undefined;
1609
+ let edges = 0;
1610
+ for (let i = open.order.length - 1; i >= 0; i -= 1) {
1611
+ const placed = open.order[i];
1612
+ if (placed.kind === 'edge') {
1613
+ edges += placed.item.width;
1614
+ continue;
1615
+ }
1616
+ if (placed.kind === 'atomic')
1617
+ return undefined;
1618
+ const text = placed.item;
1619
+ const before = LAYOUT_RUNS.get(text.layout);
1620
+ if (!before || (before.length > 0 && typeof before[0] === 'number')) {
1621
+ return undefined;
1622
+ }
1623
+ const last = charAt(before, text.layout.lines[text.layoutLine].end - 1);
1624
+ return last === '' || SPACE.test(last) ? undefined : edges;
1625
+ }
1626
+ return undefined;
1627
+ }
1628
+ /**
1629
+ * Whether a line may break between two texts an inline box's edge stands
1630
+ * between — the text of `before` up to the code unit `end`, and `after` —
1631
+ * which is where it may break them run together, the edge being no place
1632
+ * to break (CSS Text 3, 5.1). After white space, and not before it, where
1633
+ * the break is after that white space instead (UAX #14, LB7). Never between
1634
+ * two letters or digits of the alphabets whose words break only at spaces,
1635
+ * which is every edge in running text, and needs no asking. Anything else
1636
+ * — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
1637
+ * of the text engine: the two words, laid out at no width, break at every
1638
+ * place a line may, which is UAX #14 as the `linebreak` package has it on
1639
+ * both backends — ntk breaks every line with it, and react-x11's CoreText
1640
+ * engine a text at no width. So an edge after a hyphen, or between two
1641
+ * ideographs, is a place to break exactly where the same text with no edge
1642
+ * in it is one; a list of classes here would be a third opinion.
1643
+ *
1644
+ * Counted, not located: the words run together break into as many pieces
1645
+ * as the two apart where they break between them, and one fewer where the
1646
+ * last piece of the one runs on into the first of the other. The line
1647
+ * offsets are no answer, as they are not the text's on every backend: the
1648
+ * CoreText engine lays out a copy with a line break put in at each place,
1649
+ * and its offsets are that copy's.
1650
+ */
1651
+ function breaksBetween(fonts, base, before, end, after, anywhere) {
1652
+ const last = charAt(before, end - 1);
1653
+ const next = charAt(after, 0);
1654
+ // nothing to hold the two together
1655
+ if (last === '' || next === '')
1656
+ return true;
1657
+ if (SPACE.test(last))
1658
+ return true;
1659
+ if (SPACE.test(next))
1660
+ return false;
1661
+ if (anywhere)
1662
+ return true;
1663
+ if (HOLDS.test(last) && HOLDS.test(next))
1664
+ return false;
1665
+ if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next))
1666
+ return false;
1667
+ const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
1668
+ const head = sliceRuns(after, 0, firstSpace(after));
1669
+ const pieces = (runs) => fonts.layout(runs, base, {
1670
+ maxWidth: MIN_CONTENT_WIDTH,
1671
+ overflowWrap: 'normal',
1672
+ }).lines.length;
1673
+ return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
1674
+ }
1675
+ /** The width a text is laid out at to break it at every place it may
1676
+ * break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
1677
+ * `MIN_CONTENT_PROBE`). */
1678
+ const MIN_CONTENT_WIDTH = 0;
1679
+ /** White space a line breaks after. */
1680
+ const SPACE = /^[ \t\n]$/;
1681
+ /** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
1682
+ * marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
1683
+ * LB25, LB28). */
1684
+ const HOLDS = /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
1685
+ /** What no line breaks after, whatever follows: an opening bracket, a
1686
+ * straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
1687
+ const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
1688
+ /** What no line breaks before, whatever precedes: a closing bracket, a
1689
+ * stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
1690
+ * LB13, LB19) — the punctuation after a link or a `<code>`, which needs
1691
+ * no asking either. */
1692
+ const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
1693
+ /** The code unit at an offset into runs' joined text, or '' outside it. */
1694
+ function charAt(runs, at) {
1695
+ if (at < 0)
1696
+ return '';
1697
+ let from = 0;
1698
+ for (const run of runs) {
1699
+ const next = from + run.text.length;
1700
+ if (at < next)
1701
+ return run.text[at - from];
1702
+ from = next;
1703
+ }
1704
+ return '';
1705
+ }
1706
+ /** Where the last white space before a code-unit offset is, or -1. */
1707
+ function lastSpace(runs, end) {
1708
+ let found = -1;
1709
+ let from = 0;
1710
+ for (const run of runs) {
1711
+ if (from >= end)
1712
+ break;
1713
+ const text = run.text.slice(0, end - from);
1714
+ const at = Math.max(text.lastIndexOf(' '), text.lastIndexOf('\t'), text.lastIndexOf('\n'));
1715
+ if (at >= 0)
1716
+ found = from + at;
1717
+ from += run.text.length;
1718
+ }
1719
+ return found;
1720
+ }
1721
+ /** Where the first white space in runs' joined text is, or its length. */
1722
+ function firstSpace(runs) {
1723
+ let from = 0;
1724
+ for (const run of runs) {
1725
+ const at = run.text.search(/[ \t\n]/);
1726
+ if (at >= 0)
1727
+ return from + at;
1728
+ from += run.text.length;
1729
+ }
1730
+ return from;
1731
+ }
1732
+ function runsLength(runs) {
1733
+ let length = 0;
1734
+ for (const run of runs)
1735
+ length += run.text.length;
1736
+ return length;
1737
+ }
1738
+ /**
1739
+ * The text inline boxes' edges from `from` on stand before, up to its first
1740
+ * white space: what the text before the edges runs on into. Null where an
1741
+ * atomic comes first, which has a break before it, or nothing does.
1742
+ */
1743
+ function textAfterEdges(items, from) {
1744
+ const runs = [];
1745
+ for (let i = from; i < items.length; i += 1) {
1746
+ const item = items[i];
1747
+ if (item.kind === 'edge' || item.kind === 'float')
1748
+ continue;
1749
+ if (item.kind === 'atomic')
1750
+ break;
1751
+ const text = item.run.text;
1752
+ const stop = text.search(/[ \t\n]/);
1753
+ if (stop < 0) {
1754
+ runs.push(item.run);
1755
+ continue;
1756
+ }
1757
+ runs.push({ ...item.run, text: text.slice(0, stop + 1) });
1758
+ break;
1759
+ }
1760
+ return runs.length ? runs : null;
1761
+ }
1762
+ /**
1763
+ * The most `unbreakableAfter` could come to, with no layout: the edges on
1764
+ * the way, and the text up to its first white space at half again its size
1765
+ * a character, which no glyph a document sets words in is wider than, and
1766
+ * its letter spacing (`longestWord`'s estimate).
1767
+ */
1768
+ function unbreakableBound(items, from) {
1769
+ let width = 0;
1770
+ for (let i = from; i < items.length; i += 1) {
1771
+ const item = items[i];
1772
+ if (item.kind === 'float')
1773
+ continue;
1774
+ if (item.kind === 'edge') {
1775
+ width += Math.max(0, item.width);
1776
+ continue;
1777
+ }
1778
+ if (item.kind === 'atomic')
1779
+ break;
1780
+ const run = item.run;
1781
+ const stop = run.text.search(/[ \t\n]/);
1782
+ const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
1783
+ width += (stop < 0 ? run.text.length : stop) * per;
1784
+ if (stop >= 0)
1785
+ break;
1786
+ }
1787
+ return width;
1788
+ }
1789
+ /** How wide the edges from `from` on are that close inline boxes, up to
1790
+ * one that opens a box or anything but an edge or a float. */
1791
+ function closingEdges(items, from) {
1792
+ let width = 0;
1793
+ for (let i = from; i < items.length; i += 1) {
1794
+ const item = items[i];
1795
+ if (item.kind === 'float')
1796
+ continue;
1797
+ if (item.kind !== 'edge' || item.side !== 'end')
1798
+ break;
1799
+ width += item.width;
1800
+ }
1801
+ return width;
1802
+ }
1803
+ /** Whether a code-unit offset into runs' joined text is between two
1804
+ * letters or digits: inside a word, where only `overflow-wrap` breaks. */
1805
+ function insideWord(runs, at) {
1806
+ return (WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at)));
1807
+ }
1348
1808
  /**
1349
1809
  * How wide the content from `from` on is up to where its line may break:
1350
1810
  * its text up to a space it may break at — one `holdNoWrap` left a space —
@@ -1380,7 +1840,9 @@ function unbreakableAfter(items, from, style, fonts, base) {
1380
1840
  runs.push({ ...item.run, text: text.slice(0, stop) });
1381
1841
  break;
1382
1842
  }
1383
- return runs.length ? width + fonts.layout(runs, base, {}).width : width;
1843
+ return runs.length
1844
+ ? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
1845
+ : width;
1384
1846
  }
1385
1847
  /**
1386
1848
  * Close a line: its height and baseline from what is on it, then where it
@@ -1388,11 +1850,16 @@ function unbreakableAfter(items, from, style, fonts, base) {
1388
1850
  * it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
1389
1851
  * centred), moved onto every text, atomic and edge on it at once.
1390
1852
  */
1391
- function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null) {
1853
+ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null,
1854
+ /** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
1855
+ levels = null) {
1392
1856
  if (!open.texts.length && !open.atomics.length && !open.edges.length) {
1393
1857
  return null;
1394
1858
  }
1395
- const reordered = open.order.some((p) => needsOrdering(p, rtl));
1859
+ const reordered = levels
1860
+ ? levelPieces(open, levels) ||
1861
+ open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
1862
+ : open.order.some((p) => needsOrdering(p, rtl));
1396
1863
  if (reordered)
1397
1864
  reorderLine(open, rtl);
1398
1865
  // Every line box starts with the block's strut, its face at its line
@@ -1435,7 +1902,9 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1435
1902
  continue;
1436
1903
  }
1437
1904
  const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
1438
- const own = natural.baseline - natural.y;
1905
+ // with its leading shared as the strut's is: it is drawn on the line's
1906
+ // baseline, wherever its engine put its own
1907
+ const own = lineAscent(natural);
1439
1908
  ascent = Math.max(ascent, own);
1440
1909
  descent = Math.max(descent, natural.height - own);
1441
1910
  }
@@ -1601,10 +2070,8 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
1601
2070
  * UAX #9's L2 over pieces rather than characters: a fragment is in order
1602
2071
  * inside itself already — the engine laid it out bidirectionally — so what
1603
2072
  * 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).
2073
+ * a level: the paragraph's own, where it has them (`levelPieces`), and
2074
+ * otherwise the one it reads at (`readingLevels`).
1608
2075
  *
1609
2076
  * Then each element's edges, innermost first, around what of it is on the
1610
2077
  * line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
@@ -1623,28 +2090,16 @@ function reorderLine(open, rtl) {
1623
2090
  for (let i = 0; i < n; i += 1)
1624
2091
  if (order[i].kind !== 'edge')
1625
2092
  content.push(i);
1626
- const strong = content.map((i) => {
2093
+ // UAX #9's levels where the paragraph's are known (`levelPieces`), each
2094
+ // piece at one of them; its reading otherwise
2095
+ const given = content.map((i) => {
1627
2096
  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;
2097
+ return p.kind === 'edge' ? undefined : p.level;
1644
2098
  });
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));
2099
+ const levels = given.every((level) => level !== undefined)
2100
+ ? given
2101
+ : readingLevels(order, content, rtl);
2102
+ const isRtl = levels.map((level) => (level & 1) === 1);
1648
2103
  const visual = content.map((_, k) => k);
1649
2104
  for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
1650
2105
  for (let a = 0; a < visual.length;) {
@@ -1719,6 +2174,9 @@ function reorderLine(open, rtl) {
1719
2174
  if (isRtl[k] && natural)
1720
2175
  dx += Math.max(0, room - natural.width);
1721
2176
  piece.item.drawX += dx;
2177
+ // and the spaces it hung are on the side of it its room is left on
2178
+ if (piece.item.hung)
2179
+ placeHung(piece.item, isRtl[k]);
1722
2180
  }
1723
2181
  else {
1724
2182
  piece.item.x += dx;
@@ -1726,6 +2184,399 @@ function reorderLine(open, rtl) {
1726
2184
  x += room;
1727
2185
  }
1728
2186
  }
2187
+ /**
2188
+ * The levels of a line's pieces from which way each reads, where the
2189
+ * paragraph's are not known: a fragment the direction its letters read in,
2190
+ * or the paragraph's where they read both ways, and a neutral — an atomic, a
2191
+ * fragment of spaces and digits — its neighbours' where they agree and the
2192
+ * paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
2193
+ * L is 0 in a left-to-right one and 2 in a right-to-left one.
2194
+ */
2195
+ function readingLevels(order, content, rtl) {
2196
+ const strong = content.map((i) => {
2197
+ const p = order[i];
2198
+ if (p.kind !== 'text' || p.reads === null)
2199
+ return null;
2200
+ return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
2201
+ });
2202
+ const strongAt = (from, step) => {
2203
+ for (let k = from + step; k >= 0 && k < content.length; k += step) {
2204
+ if (strong[k] !== null)
2205
+ return strong[k];
2206
+ }
2207
+ return rtl;
2208
+ };
2209
+ return strong.map((r, k) => {
2210
+ const reads = r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
2211
+ return reads ? 1 : rtl ? 2 : 0;
2212
+ });
2213
+ }
2214
+ let BIDI = null;
2215
+ /** bidi-js, made the first time a paragraph needs it. Its declarations
2216
+ * give a CommonJS module a default export, which NodeNext reads as the
2217
+ * module object; Node and a bundler both hand over its factory. */
2218
+ function bidiJs() {
2219
+ if (!BIDI) {
2220
+ const factory = bidiModule;
2221
+ BIDI = (typeof factory === 'function' ? factory : factory.default)();
2222
+ }
2223
+ return BIDI;
2224
+ }
2225
+ /** A paragraph's levels, or null where nothing in it can be reordered:
2226
+ * it reads left to right, and holds no right-to-left letter and no bidi
2227
+ * control. Kept on its block, and resolved again only where its text is
2228
+ * not what it was: a pass at another width has the same. */
2229
+ function paragraphBidi(block, items, rtl) {
2230
+ let reorders = rtl;
2231
+ for (let i = 0; !reorders && i < items.length; i += 1) {
2232
+ const item = items[i];
2233
+ if (item.kind === 'text' && REORDERS.test(item.run.text))
2234
+ reorders = true;
2235
+ }
2236
+ if (!reorders)
2237
+ return null;
2238
+ const starts = [];
2239
+ let text = '';
2240
+ for (const item of items) {
2241
+ starts.push(text.length);
2242
+ if (item.kind === 'text')
2243
+ text += item.run.text;
2244
+ else if (item.kind === 'atomic')
2245
+ text += '\ufffc';
2246
+ }
2247
+ const base = rtl ? 1 : 0;
2248
+ const kept = PARAGRAPH_BIDI.get(block);
2249
+ if (kept && kept.text === text && kept.base === base) {
2250
+ return { ...kept, starts };
2251
+ }
2252
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2253
+ const explicit = EXPLICIT.test(text);
2254
+ const bidi = { text, levels, explicit, base, starts };
2255
+ PARAGRAPH_BIDI.set(block, bidi);
2256
+ return bidi;
2257
+ }
2258
+ const PARAGRAPH_BIDI = new WeakMap();
2259
+ /** The explicit embeddings, overrides and isolates. */
2260
+ const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
2261
+ /** The explicit embeddings, overrides and isolates a text ends on. */
2262
+ const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
2263
+ /** Whether a character is one X9 removes, or an isolate: a bidi control,
2264
+ * or a boundary neutral such as a zero-width space or a soft hyphen. */
2265
+ function isControl(c) {
2266
+ return ((c >= 0x202a && c <= 0x202e) ||
2267
+ (c >= 0x2066 && c <= 0x2069) ||
2268
+ (c >= 0x200b && c <= 0x200d) ||
2269
+ (c >= 0x2060 && c <= 0x2064) ||
2270
+ c === 0xad ||
2271
+ c === 0xfeff ||
2272
+ c === 0x180e);
2273
+ }
2274
+ /** The paired brackets UAX #9 resolves together (N0), which may pair
2275
+ * across a piece's end. */
2276
+ 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]/;
2277
+ /** The strong types, which nothing around them resolves again. */
2278
+ const STRONG = new Set(['L', 'R', 'AL']);
2279
+ /** What UAX #9 L1 puts back at the paragraph's level where a line ends on
2280
+ * it: white space, the isolates, and what X9 removed. */
2281
+ const TRAILING = new Set([
2282
+ 'WS',
2283
+ 'S',
2284
+ 'B',
2285
+ 'LRI',
2286
+ 'RLI',
2287
+ 'FSI',
2288
+ 'PDI',
2289
+ 'BN',
2290
+ 'LRE',
2291
+ 'RLE',
2292
+ 'LRO',
2293
+ 'RLO',
2294
+ 'PDF',
2295
+ ]);
2296
+ /** A segment's text, and its levels as the engine resolves them, laid out
2297
+ * alone in the paragraph's direction. */
2298
+ const ALONE = new WeakMap();
2299
+ function aloneLevels(runs, rtl) {
2300
+ let alone = ALONE.get(runs);
2301
+ if (!alone) {
2302
+ const text = runs.map((run) => run.text).join('');
2303
+ const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
2304
+ alone = { text, levels };
2305
+ ALONE.set(runs, alone);
2306
+ }
2307
+ return alone;
2308
+ }
2309
+ /** Whether the text from a segment on, laid out alone, is ordered as the
2310
+ * paragraph orders it: every letter at the level it has there. */
2311
+ function orderedAlone(bidi, segment, para, rtl) {
2312
+ const { text, levels } = aloneLevels(segment.runs, rtl);
2313
+ for (let at = 0; at < levels.length; at += 1) {
2314
+ if (segment.spans.isGap(at))
2315
+ continue;
2316
+ if (levels[at] === bidi.levels[para + at])
2317
+ continue;
2318
+ if (!isControl(text.charCodeAt(at)))
2319
+ return false;
2320
+ }
2321
+ return true;
2322
+ }
2323
+ /**
2324
+ * Give each piece on a line its level, and answer whether the line is to be
2325
+ * put in visual order at all. A piece's letters have the paragraph's levels,
2326
+ * with the white space the line ends on back at the paragraph's (L1). A
2327
+ * fragment was laid out alone and ordered inside itself by the engine: it
2328
+ * is kept whole where the engine's levels for it are the paragraph's and it
2329
+ * begins and ends at its lowest one, so that nothing of another piece comes
2330
+ * between its letters (L2), and it is the line's at that level. It is split
2331
+ * otherwise, a layout to each run of its letters at one level, held in that
2332
+ * level's direction by an override; the controls in it, which take no
2333
+ * room, are left out. An atomic is at its replacement character's level.
2334
+ */
2335
+ function levelPieces(open, line) {
2336
+ const { bidi } = line;
2337
+ let start = Infinity;
2338
+ let end = -Infinity;
2339
+ for (const p of open.order) {
2340
+ if (p.kind === 'text' && p.from) {
2341
+ const natural = p.item.layout.lines[p.item.layoutLine];
2342
+ if (!natural)
2343
+ continue;
2344
+ start = Math.min(start, p.from.para + natural.start);
2345
+ end = Math.max(end, p.from.para + natural.end);
2346
+ }
2347
+ else if (p.kind === 'atomic' && p.para !== undefined) {
2348
+ start = Math.min(start, p.para);
2349
+ end = Math.max(end, p.para + 1);
2350
+ }
2351
+ }
2352
+ // an element's edges alone are ordered as the paragraph reads
2353
+ if (!(end > start))
2354
+ return bidi.base > 0;
2355
+ const levels = bidi.levels.slice(start, end);
2356
+ const js = bidiJs();
2357
+ for (let i = end - 1; i >= start; i -= 1) {
2358
+ if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i])))
2359
+ break;
2360
+ levels[i - start] = bidi.base;
2361
+ }
2362
+ const levelAt = (at) => levels[at - start];
2363
+ let off = false;
2364
+ const order = [];
2365
+ const texts = [];
2366
+ for (const p of open.order) {
2367
+ if (p.kind === 'edge') {
2368
+ order.push(p);
2369
+ continue;
2370
+ }
2371
+ if (p.kind === 'atomic') {
2372
+ p.level = p.para === undefined ? bidi.base : levelAt(p.para);
2373
+ off ||= p.level > 0;
2374
+ order.push(p);
2375
+ continue;
2376
+ }
2377
+ for (const piece of levelled(p, levelAt, line)) {
2378
+ off ||= piece.level > 0;
2379
+ order.push(piece);
2380
+ texts.push(piece.item);
2381
+ }
2382
+ }
2383
+ open.order = order;
2384
+ open.texts = texts;
2385
+ return off;
2386
+ }
2387
+ /** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
2388
+ function levelled(p, levelAt, line) {
2389
+ const text = p.item;
2390
+ const natural = text.layout.lines[text.layoutLine];
2391
+ const base = line.bidi.base;
2392
+ if (!p.from || !natural)
2393
+ return [{ ...p, level: base }];
2394
+ const { para, runs, spans } = p.from;
2395
+ const paragraph = line.bidi.text;
2396
+ // A bidi control takes no room and has no level of its own, whether
2397
+ // `unicode-bidi` stands for it or the document holds it: X9 removes it,
2398
+ // and bidi-js gives it the level of what is before it, which may be
2399
+ // another piece's. It is no end of a piece, nor of a run of one level.
2400
+ const quiet = (at) => spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
2401
+ let lowest = Infinity;
2402
+ let first = -1;
2403
+ let last = -1;
2404
+ for (let at = natural.start; at < natural.end; at += 1) {
2405
+ if (quiet(at))
2406
+ continue;
2407
+ if (first < 0)
2408
+ first = at;
2409
+ last = at;
2410
+ lowest = Math.min(lowest, levelAt(para + at));
2411
+ }
2412
+ if (first < 0)
2413
+ return [{ ...p, level: base }];
2414
+ if (levelAt(para + first) === lowest &&
2415
+ levelAt(para + last) === lowest &&
2416
+ (plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
2417
+ laidAsLevelled(p.from, natural, levelAt, base))) {
2418
+ return [{ ...p, level: lowest }];
2419
+ }
2420
+ const starts = runStarts(runs);
2421
+ const out = [];
2422
+ let at = p.at;
2423
+ let lastRuns = [];
2424
+ /** Where each piece is in the fragment's segment. */
2425
+ const ranges = [];
2426
+ const piece = (from, to, level) => {
2427
+ // its letters, each run's with where it is in the document and whose
2428
+ // text it is
2429
+ const letters = [];
2430
+ let points = 0;
2431
+ for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
2432
+ const runStart = starts[k];
2433
+ if (runStart >= to)
2434
+ break;
2435
+ const a = Math.max(from, runStart);
2436
+ const b = Math.min(to, runStart + runs[k].text.length);
2437
+ if (b <= a || spans.isGap(a))
2438
+ continue;
2439
+ const slice = runs[k].text.slice(a - runStart, b - runStart);
2440
+ letters.push({
2441
+ run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
2442
+ doc: spans.documentAt(a),
2443
+ box: spans.boxAt(a),
2444
+ });
2445
+ points += codeUnitOffsets(slice).length - 1;
2446
+ }
2447
+ if (!letters.length)
2448
+ return;
2449
+ const head = letters[0];
2450
+ const tail = letters[letters.length - 1];
2451
+ const docStart = head.doc;
2452
+ const docEnd = tail.doc + tail.run.text.length;
2453
+ const face = (run, control) => ({
2454
+ text: control,
2455
+ family: run.family,
2456
+ size: run.size,
2457
+ weight: run.weight,
2458
+ style: run.style,
2459
+ });
2460
+ const rtl = (level & 1) === 1;
2461
+ const laidRuns = [face(head.run, rtl ? '\u202e' : '\u202d')];
2462
+ const laidSpans = new SpanMap();
2463
+ laidSpans.add(docStart, 1, head.box, true);
2464
+ for (const { run, doc, box } of letters) {
2465
+ laidSpans.add(doc, run.text.length, box);
2466
+ laidRuns.push(run);
2467
+ }
2468
+ laidRuns.push(face(tail.run, '\u202c'));
2469
+ laidSpans.add(docEnd, 1, tail.box, true);
2470
+ const layout = line.fonts.layout(laidRuns, line.base, {
2471
+ lineHeight: line.lineHeight,
2472
+ align: 'left',
2473
+ direction: rtl ? 'rtl' : 'ltr',
2474
+ fit: PARAGRAPH_FIT,
2475
+ });
2476
+ LAYOUT_RUNS.set(layout, laidRuns);
2477
+ lastRuns = laidRuns;
2478
+ const laid = layout.lines[0];
2479
+ const item = {
2480
+ layout,
2481
+ layoutLine: 0,
2482
+ drawX: text.drawX + (at - p.at),
2483
+ drawY: 0,
2484
+ textStart: docStart,
2485
+ textEnd: docEnd,
2486
+ layoutStart: laid ? laid.start : 0,
2487
+ spans: laidSpans,
2488
+ };
2489
+ laidSpans.giveGaps(item, 0, laidSpans.laidOut);
2490
+ out.push({ kind: 'text', at, item, reads: null, level });
2491
+ ranges.push([from, to]);
2492
+ // the room it takes, its white space at its end with it, which the
2493
+ // engine hangs past the layout's width: from caret to caret
2494
+ const extent = Math.abs(layout.caretPosition(1 + points).x - layout.caretPosition(1).x);
2495
+ at += Math.max(laid?.width ?? 0, extent);
2496
+ };
2497
+ let from = first;
2498
+ let level = levelAt(para + first);
2499
+ let prev = first;
2500
+ for (let o = first + 1; o <= last; o += 1) {
2501
+ if (quiet(o))
2502
+ continue;
2503
+ const l = levelAt(para + o);
2504
+ if (l !== level) {
2505
+ piece(from, prev + 1, level);
2506
+ from = o;
2507
+ level = l;
2508
+ }
2509
+ prev = o;
2510
+ }
2511
+ piece(from, last + 1, level);
2512
+ if (!out.length)
2513
+ return [{ ...p, level: base }];
2514
+ // a word across two of the pieces runs on from one to the next, with no
2515
+ // edge between, as the fragment ran on from the text before it (`JOINS`)
2516
+ const joins = JOINS.get(text);
2517
+ if (joins !== undefined)
2518
+ JOINS.set(out[0].item, joins);
2519
+ for (let k = 1; k < out.length; k += 1) {
2520
+ const before = paragraph[para + ranges[k - 1][1] - 1];
2521
+ const after = paragraph[para + ranges[k][0]];
2522
+ if (!SPACE.test(before) && !SPACE.test(after))
2523
+ JOINS.set(out[k].item, 0);
2524
+ }
2525
+ // the spaces `pre-wrap` keeps that the fragment ended on are its last
2526
+ // piece's, which the engine hangs as it hung them (`hangPreserved`)
2527
+ const tail = out[out.length - 1];
2528
+ if (text.hung) {
2529
+ const rtl = (tail.level & 1) === 1;
2530
+ hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
2531
+ }
2532
+ return out;
2533
+ }
2534
+ /**
2535
+ * Whether the engine, laying a fragment out alone, must have given its
2536
+ * letters the paragraph's levels, without resolving them to see: in a
2537
+ * paragraph of no explicit embedding, a stretch that begins and ends with a
2538
+ * strong letter and pairs no bracket is resolved inside itself, whatever is
2539
+ * around it (W1 to W7, N1 and N2 look no further than the strong letters
2540
+ * either side). The white space it ends on is the paragraph's level to the
2541
+ * engine, whose line ends there (L1), and has to be to the line.
2542
+ */
2543
+ function plainlyLevelled(bidi, from, first, last, levelAt) {
2544
+ if (bidi.explicit)
2545
+ return false;
2546
+ const text = bidi.text;
2547
+ const js = bidiJs();
2548
+ let end = last;
2549
+ while (end > first &&
2550
+ TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))) {
2551
+ if (levelAt(from.para + end) !== bidi.base)
2552
+ return false;
2553
+ end -= 1;
2554
+ }
2555
+ return (STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
2556
+ STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
2557
+ !BRACKETS.test(text.slice(from.para + first, from.para + end + 1)));
2558
+ }
2559
+ /** Whether the engine, laying a fragment out alone, gave its letters the
2560
+ * levels the paragraph gives them: with its line's end white space back
2561
+ * at the paragraph's level (L1), as the engine's own line ends there. */
2562
+ function laidAsLevelled(from, natural, levelAt, base) {
2563
+ const { text, levels } = aloneLevels(from.runs, base === 1);
2564
+ const js = bidiJs();
2565
+ const quiet = (at) => from.spans.isGap(at) || isControl(text.charCodeAt(at));
2566
+ let hung = natural.end;
2567
+ while (hung > natural.start &&
2568
+ TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))) {
2569
+ hung -= 1;
2570
+ }
2571
+ for (let at = natural.start; at < natural.end; at += 1) {
2572
+ if (quiet(at))
2573
+ continue;
2574
+ const alone = at >= hung ? base : levels[at];
2575
+ if (alone !== levelAt(from.para + at))
2576
+ return false;
2577
+ }
2578
+ return true;
2579
+ }
1729
2580
  /** Whether a piece can be out of place in logical order: anything on a
1730
2581
  * right-to-left line, a fragment that reads right to left, or the edge of
1731
2582
  * an element whose direction is not the line's. A line of left-to-right
@@ -1843,7 +2694,14 @@ function atomicRaise(fonts, box, parent) {
1843
2694
  function atomicBaseline(box) {
1844
2695
  const bottom = box.height + box.marginTop + box.marginBottom;
1845
2696
  if (box.kind === 'replaced' ||
1846
- scrolls(box.style) ||
2697
+ // a box that clips sits on its bottom margin edge, for legacy reasons
2698
+ // that stop at block containers (CSS Box Alignment 3, 9.2) — and an
2699
+ // inline table, as browsers keep it: an inline flex box that clips
2700
+ // sits on its first item's, as it does unclipped. A MediaWiki button
2701
+ // is an `overflow: hidden` inline flex box of one icon, and stood its
2702
+ // whole height on the baseline, the line under it the strut's descent
2703
+ // taller
2704
+ (scrolls(box.style) && box.kind !== 'flex') ||
1847
2705
  // layout containment keeps its baseline in (CSS Containment 2, 3.3)
1848
2706
  box.style.contain & CONTAIN_LAYOUT) {
1849
2707
  return bottom;
@@ -1976,7 +2834,11 @@ function childBaseline(child, inside) {
1976
2834
  if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
1977
2835
  return null;
1978
2836
  }
1979
- if (scrolls(child.style)) {
2837
+ // the legacy rule is a block container's last baseline alone (CSS Box
2838
+ // Alignment 3, 9.2): its first is its first line's, clipped or not
2839
+ if (inside === lastBaselineIn &&
2840
+ child.kind !== 'flex' &&
2841
+ scrolls(child.style)) {
1980
2842
  return child.y + child.height + child.marginBottom;
1981
2843
  }
1982
2844
  return inside(child);
@@ -2339,7 +3201,12 @@ function wordSpaced(out, box, run, spacing) {
2339
3201
  run: {
2340
3202
  ...run,
2341
3203
  text: text.slice(from, to),
2342
- ...(extra ? { letterSpacing: (run.letterSpacing ?? 0) + extra } : null),
3204
+ ...(extra
3205
+ ? {
3206
+ letterSpacing: (run.letterSpacing ?? 0) + extra,
3207
+ kernAcross: true,
3208
+ }
3209
+ : null),
2343
3210
  },
2344
3211
  box,
2345
3212
  length: to - from,
@@ -2385,6 +3252,11 @@ function inlineEdges(box, width, fonts) {
2385
3252
  }
2386
3253
  }
2387
3254
  box.decoration = inlineDecoration(fonts, box, s);
3255
+ if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
3256
+ PADDED_FACES.set(box, faceExtent(fonts, s));
3257
+ }
3258
+ else
3259
+ PADDED_FACES.delete(box);
2388
3260
  const left = box.marginLeft + box.borderLeft + box.padLeft;
2389
3261
  const right = box.padRight + box.borderRight + box.marginRight;
2390
3262
  return s.direction === 'rtl' ? [right, left] : [left, right];
@@ -2833,8 +3705,13 @@ first = null) {
2833
3705
  const holds = i === 0 && first ? first : strut;
2834
3706
  if (!holds)
2835
3707
  continue;
2836
- if (line.baseline < holds.ascent - 0.5 ||
2837
- line.height - line.baseline < holds.descent - 0.5) {
3708
+ // its room as the strut's is worked out, whichever baseline the layout
3709
+ // it is drawn with could keep (`layoutLift`)
3710
+ const text = line.texts.length === 1 ? line.texts[0] : null;
3711
+ const natural = text?.layout.lines[text.layoutLine];
3712
+ const ascent = natural ? lineAscent(natural) : line.baseline;
3713
+ if (ascent < holds.ascent - 0.5 ||
3714
+ line.height - ascent < holds.descent - 0.5) {
2838
3715
  return true;
2839
3716
  }
2840
3717
  }
@@ -3094,11 +3971,15 @@ class SpanMap {
3094
3971
  * a spacer, a bidi control. */
3095
3972
  _gaps = null;
3096
3973
  laidOut = 0;
3097
- add(documentStart, length, box = null) {
3974
+ add(documentStart, length, box = null,
3975
+ /** No text of the document's, though a box's: a bidi control a
3976
+ * level's layout is wrapped in (`levelPieces`), which answers for
3977
+ * the element around it. */
3978
+ gap = box === null) {
3098
3979
  this._laid.push(this.laidOut);
3099
3980
  this._doc.push(documentStart);
3100
3981
  this._boxes.push(box);
3101
- if (!box) {
3982
+ if (gap) {
3102
3983
  this._gaps ??= [];
3103
3984
  for (let i = 0; i < length; i += 1)
3104
3985
  this._gaps.push(this.laidOut + i);
@@ -3115,6 +3996,23 @@ class SpanMap {
3115
3996
  if (gaps.length)
3116
3997
  text.gaps = gaps;
3117
3998
  }
3999
+ /** Whether an offset in the laid-out text is no text of the
4000
+ * document's: a spacer, a bidi control. */
4001
+ isGap(offset) {
4002
+ const gaps = this._gaps;
4003
+ if (!gaps)
4004
+ return false;
4005
+ let lo = 0;
4006
+ let hi = gaps.length;
4007
+ while (lo < hi) {
4008
+ const mid = (lo + hi) >> 1;
4009
+ if (gaps[mid] < offset)
4010
+ lo = mid + 1;
4011
+ else
4012
+ hi = mid;
4013
+ }
4014
+ return gaps[lo] === offset;
4015
+ }
3118
4016
  /** The text box whose text an offset in the laid-out text is. */
3119
4017
  boxAt(offset) {
3120
4018
  const i = this._entryAt(offset);
@@ -3245,6 +4143,165 @@ export function hungSpaces(fonts, run) {
3245
4143
  }
3246
4144
  return hung;
3247
4145
  }
4146
+ /** Where each run starts in their joined text. */
4147
+ function runStarts(runs) {
4148
+ const starts = [];
4149
+ let at = 0;
4150
+ for (const run of runs) {
4151
+ starts.push(at);
4152
+ at += run.text.length;
4153
+ }
4154
+ return starts;
4155
+ }
4156
+ /** The run a code-unit offset in the runs' joined text falls in. */
4157
+ function runIndexAt(starts, offset) {
4158
+ let lo = 0;
4159
+ let hi = starts.length - 1;
4160
+ while (lo < hi) {
4161
+ const mid = (lo + hi + 1) >> 1;
4162
+ if (starts[mid] <= offset)
4163
+ lo = mid;
4164
+ else
4165
+ hi = mid - 1;
4166
+ }
4167
+ return lo;
4168
+ }
4169
+ /**
4170
+ * The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
4171
+ * logical order and each with its advance, and whether a forced break
4172
+ * follows them. An engine strips a line's trailing white space, which is
4173
+ * right for the collapsible spaces CSS removes there, and wrong for these.
4174
+ */
4175
+ function keptAtEnd(natural, runs, starts, spans, fonts) {
4176
+ const boxAt = spans.boxAt;
4177
+ if (!boxAt || !runs.length)
4178
+ return { found: null, forced: false };
4179
+ const charAt = (offset) => {
4180
+ const k = runIndexAt(starts, offset);
4181
+ return runs[k].text.charCodeAt(offset - starts[k]);
4182
+ };
4183
+ let end = natural.end;
4184
+ let forced = false;
4185
+ if (end > natural.start && charAt(end - 1) === 10) {
4186
+ forced = true;
4187
+ end -= 1;
4188
+ }
4189
+ let found = null;
4190
+ let kept = false;
4191
+ for (let at = end - 1; at >= natural.start; at -= 1) {
4192
+ const c = charAt(at);
4193
+ if (isControl(c))
4194
+ continue;
4195
+ if (c !== 0x20)
4196
+ break;
4197
+ // a collapsible space after the last kept one is removed (4.1.3), and
4198
+ // one before a kept one hangs with it
4199
+ const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
4200
+ if (!keeps && !kept)
4201
+ continue;
4202
+ kept = true;
4203
+ const run = runs[runIndexAt(starts, at)];
4204
+ (found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
4205
+ }
4206
+ found?.reverse();
4207
+ return { found, forced };
4208
+ }
4209
+ /**
4210
+ * The spaces `pre-wrap` keeps that a text's line ends on, which the engine
4211
+ * hung (`keptAtEnd`): past the line's end, to its left where the layout
4212
+ * reads right to left, and their inline box's, whose background and border
4213
+ * cover them, as a browser draws them. Noted on the text (`LineText.hung`),
4214
+ * and their advance answered, with whether a forced break follows them.
4215
+ */
4216
+ function hangPreserved(text, runs, starts, fonts, rtl) {
4217
+ const natural = text.layout.lines[text.layoutLine];
4218
+ if (!natural)
4219
+ return { total: 0, forced: false };
4220
+ const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
4221
+ if (!found)
4222
+ return { total: 0, forced };
4223
+ text.hung = found;
4224
+ placeHung(text, rtl);
4225
+ let total = 0;
4226
+ for (const space of found)
4227
+ total += space.width;
4228
+ return { total, forced };
4229
+ }
4230
+ const NOTHING_HUNG = { total: 0, forced: false };
4231
+ /** Put a text's hung spaces beside its line's end, logical order outward:
4232
+ * to the right of its glyphs, or to their left. */
4233
+ function placeHung(text, left) {
4234
+ const natural = text.layout.lines[text.layoutLine];
4235
+ if (!text.hung || !natural)
4236
+ return;
4237
+ let x = left ? natural.x : natural.x + natural.width;
4238
+ for (const space of text.hung) {
4239
+ if (left)
4240
+ x -= space.width;
4241
+ space.x = x;
4242
+ if (!left)
4243
+ x += space.width;
4244
+ }
4245
+ }
4246
+ /**
4247
+ * The runs of a layout with the spaces `pre-wrap` keeps before a forced
4248
+ * break held where they fit (`keptAtEnd`) — a kept newline, or the block's
4249
+ * end after the last line where `ends` — or null where none are. They hang
4250
+ * there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
4251
+ * which the engine gives them when they are handed over as no-break
4252
+ * spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
4253
+ * line with them. Laid out again, the lines break where they did, since
4254
+ * the spaces fit where they are. A line of a layout cannot be moved over
4255
+ * for them afterwards: its lines are drawn in one draw, from one place.
4256
+ */
4257
+ function holdAtBreaks(layout, runs, spans, fonts, width, ends) {
4258
+ const starts = runStarts(runs);
4259
+ let held = null;
4260
+ const lines = layout.lines;
4261
+ for (let i = 0; i < lines.length; i += 1) {
4262
+ const natural = lines[i];
4263
+ const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
4264
+ if (!found || !(forced || (ends && i === lines.length - 1)))
4265
+ continue;
4266
+ let free = width - natural.width;
4267
+ for (const space of found) {
4268
+ if (space.width > free + 0.01)
4269
+ break;
4270
+ free -= space.width;
4271
+ (held ??= []).push(space.at);
4272
+ }
4273
+ }
4274
+ if (!held)
4275
+ return null;
4276
+ held.sort((a, b) => a - b);
4277
+ const out = runs.slice();
4278
+ let next = 0;
4279
+ for (let k = 0; k < runs.length && next < held.length; k += 1) {
4280
+ const start = starts[k];
4281
+ const end = start + runs[k].text.length;
4282
+ if (held[next] >= end)
4283
+ continue;
4284
+ const chars = runs[k].text.split('');
4285
+ while (next < held.length && held[next] < end) {
4286
+ chars[held[next] - start] = '\u00a0';
4287
+ next += 1;
4288
+ }
4289
+ out[k] = { ...runs[k], text: chars.join('') };
4290
+ }
4291
+ return out;
4292
+ }
4293
+ /** The spaces `pre-wrap` keeps that the lines one layout made hung at
4294
+ * their ends (`hangPreserved`), noted on each line's text. */
4295
+ function hangLines(lines, from, runs, fonts, rtl) {
4296
+ let starts = null;
4297
+ for (let i = from; i < lines.length; i += 1) {
4298
+ const text = lines[i].texts[0];
4299
+ if (!text)
4300
+ continue;
4301
+ starts ??= runStarts(runs);
4302
+ hangPreserved(text, runs, starts, fonts, rtl);
4303
+ }
4304
+ }
3248
4305
  /**
3249
4306
  * A clamped paragraph with an ellipsis at the end of its last line (CSS
3250
4307
  * Overflow 4, 4.2): its text up to where that line breaks in the room the
@@ -3471,14 +4528,17 @@ function unwrappedPlacer(style, width, last = true) {
3471
4528
  }
3472
4529
  /**
3473
4530
  * 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.
4531
+ * apart, too little to move a glyph. It is not nothing, because CoreText
4532
+ * does not measure a space that is a spaced run of its own as it measures
4533
+ * the same space inside its run: it spaces a glyph with its kerning
4534
+ * attribute, which takes the place of the font's pairs. A line justified
4535
+ * from a measure of its spaces as they were came out wider than its box
4536
+ * and broke a word early: on macOS, in Helvetica, half of a paragraph did.
4537
+ * ntk keeps the pairs a spaced space makes with the letters beside it
4538
+ * (Arial's space and `T`, its `L` and space) where the run is marked
4539
+ * `kernAcross` — spacing that justifies a line is in addition to kerning
4540
+ * (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
4541
+ * a browser's is.
3482
4542
  */
3483
4543
  const HAIR = 1e-6;
3484
4544
  /** The runs with each space a run of its own, spaced a hair apart. */
@@ -3497,6 +4557,7 @@ function spacedApart(runs) {
3497
4557
  ...run,
3498
4558
  text: text[at],
3499
4559
  letterSpacing: (run.letterSpacing ?? 0) + HAIR,
4560
+ kernAcross: true,
3500
4561
  });
3501
4562
  piece = at + 1;
3502
4563
  }
@@ -3571,6 +4632,7 @@ function widenedAt(runs, extra) {
3571
4632
  ...run,
3572
4633
  text: text[at],
3573
4634
  letterSpacing: (run.letterSpacing ?? 0) + add,
4635
+ kernAcross: true,
3574
4636
  });
3575
4637
  piece = at + 1;
3576
4638
  }
@@ -3817,8 +4879,8 @@ function naturalLineHeight(fonts, style) {
3817
4879
  }
3818
4880
  /**
3819
4881
  * 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).
4882
+ * the leading its `line-height` adds shared above and below as
4883
+ * `ascentOnLine` shares it (CSS 2.1 10.8.1).
3822
4884
  */
3823
4885
  export function strutOf(fonts, style) {
3824
4886
  // text set at no size takes no room, and CoreText reads a size of 0 as
@@ -3831,8 +4893,57 @@ export function strutOf(fonts, style) {
3831
4893
  : style.lineHeightIsLength
3832
4894
  ? style.lineHeight
3833
4895
  : style.lineHeight * style.fontSize;
3834
- const half = (target - face.ascent - face.descent) / 2;
3835
- return { ascent: face.ascent + half, descent: face.descent + half };
4896
+ const ascent = ascentOnLine(face.ascent, face.descent, target);
4897
+ return { ascent, descent: target - ascent };
4898
+ }
4899
+ /**
4900
+ * How far above its baseline an inline box's room on a line reaches: its
4901
+ * face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
4902
+ * rounded down to a whole pixel, the rest of the leading going below.
4903
+ *
4904
+ * The spec's halves are exact, and where one is not a whole pixel which
4905
+ * side of the grid the text lands on is the user agent's. Blink rounds the
4906
+ * half above down (`CalculateLeadingSpace`, core/layout/inline/
4907
+ * line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
4908
+ * 15 and 2px under. Split evenly, that box reached half a pixel above the
4909
+ * 12px strut beside it, whose 4px of leading divides, and every such line
4910
+ * came out 18.5px tall.
4911
+ */
4912
+ export function ascentOnLine(ascent, descent, height) {
4913
+ return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
4914
+ }
4915
+ /**
4916
+ * How far above its baseline a text engine's line reaches, its leading
4917
+ * shared as `ascentOnLine` shares it. The engines split it evenly (the
4918
+ * contract every text engine owes, a baseline `(height - ascent -
4919
+ * descent) / 2 + ascent` down), so a line on its own sits up to a pixel
4920
+ * lower than a browser's; one that did not say how tall its face is keeps
4921
+ * the engine's.
4922
+ */
4923
+ function lineAscent(natural) {
4924
+ const { ascent, descent } = natural;
4925
+ if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
4926
+ return natural.baseline - natural.y;
4927
+ }
4928
+ return ascentOnLine(ascent, descent, natural.height);
4929
+ }
4930
+ /**
4931
+ * How far up a layout drawn whole moves for each of its lines' baselines
4932
+ * to be where `lineAscent` puts it. A layout is drawn in one batch, from
4933
+ * one origin, so it moves by one amount or not at all: where its lines
4934
+ * would move by different amounts — a face of other metrics on one — it
4935
+ * keeps the engine's baselines, which are less than a pixel out.
4936
+ */
4937
+ function layoutLift(layout) {
4938
+ let lift = NaN;
4939
+ for (const natural of layout.lines) {
4940
+ const own = natural.baseline - natural.y - lineAscent(natural);
4941
+ if (Number.isNaN(lift))
4942
+ lift = own;
4943
+ else if (Math.abs(own - lift) > 1e-6)
4944
+ return 0;
4945
+ }
4946
+ return Number.isNaN(lift) ? 0 : lift;
3836
4947
  }
3837
4948
  /**
3838
4949
  * The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
@@ -4008,12 +5119,18 @@ const NO_LIFT = { raise: 0, edge: null, lead: false };
4008
5119
  * and size is past at once, and any other answer is kept on its style.
4009
5120
  */
4010
5121
  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>
5122
+ // the block's own face is past at once. A bold or an italic one is not:
5123
+ // a paragraph is thick with <strong>, <em> and <a>, and most families'
5124
+ // faces share their line metrics, but not every one's — Helvetica Neue
5125
+ // Bold reaches 0.975em above its baseline to the regular's 0.952, and
5126
+ // Blink, which rounds each to a whole pixel, sets a 12px bold word on a
5127
+ // 19.2px line a pixel taller. It is measured once for its style, below.
4013
5128
  if (style.lineHeight === block.lineHeight &&
4014
5129
  style.lineHeightIsLength === block.lineHeightIsLength &&
4015
5130
  style.fontSize === block.fontSize &&
4016
- style.fontFamily === block.fontFamily) {
5131
+ style.fontFamily === block.fontFamily &&
5132
+ style.fontWeight === block.fontWeight &&
5133
+ style.fontStyle === block.fontStyle) {
4017
5134
  return false;
4018
5135
  }
4019
5136
  const known = LEADS.get(style);
@@ -4126,7 +5243,7 @@ function tooNarrow(runs, end, width, room) {
4126
5243
  }
4127
5244
  if (WORD_CHAR.test(before) && WORD_CHAR.test(after))
4128
5245
  return true;
4129
- return width > room + 0.5 && before !== '';
5246
+ return width > room + FIT_SLACK && before !== '';
4130
5247
  }
4131
5248
  const WORD_CHAR = /^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
4132
5249
  function bandAt(options, y, height) {