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