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