@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
|
@@ -29,11 +29,22 @@
|
|
|
29
29
|
// *not* carry is its element: a layout outlives the parse it was made for
|
|
30
30
|
// (`TextLayoutCache`), so the element under a run is found from its text's
|
|
31
31
|
// place in the document (`LineText.spans`) instead.
|
|
32
|
+
import bidiModule from 'bidi-js';
|
|
32
33
|
import { codePointAtOffset, codeUnitOffsets } from '../../internal/text.js';
|
|
33
34
|
import { CONTAIN_LAYOUT, INLINE_BEFORE_ABSOLUTE, scrolls, } from '../css/style.js';
|
|
34
35
|
import { inkColor, isTransparent, resolve } from '../css/values.js';
|
|
35
|
-
import { BOX_RAISES, LINE_BOX_RAISES, isOffset, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, } from './boxes.js';
|
|
36
|
+
import { BOX_RAISES, LINE_BOX_RAISES, PADDED_FACES, isOffset, letteredAfter, SHADOWED_TEXT, SHIFTED_LINES, TEXT_RAISES, transformInPlace, } from './boxes.js';
|
|
36
37
|
import { tableGrid } from './grid.js';
|
|
38
|
+
/** How a paragraph's lines are fitted to their width, and measured: as a
|
|
39
|
+
* browser fits them (`FontsLike`'s `fit`). */
|
|
40
|
+
const PARAGRAPH_FIT = 'items';
|
|
41
|
+
/** How far past the room a line's content may reach and still fit it: a
|
|
42
|
+
* 64th of a pixel, the epsilon Blink's line breaker adds to the width it
|
|
43
|
+
* fits to (`LineBreaker::AvailableWidthToFit`), and the one the engine
|
|
44
|
+
* fits a paragraph's lines with. A line composed a piece at a time asks
|
|
45
|
+
* the same question of each piece, and half a pixel let a word run a
|
|
46
|
+
* third of one past the line a browser breaks before it. */
|
|
47
|
+
const FIT_SLACK = 1 / 64;
|
|
37
48
|
/**
|
|
38
49
|
* The text behind a layout, for the one caller that needs it: a selection
|
|
39
50
|
* band has to turn a document offset into a **code point** index, which is
|
|
@@ -46,6 +57,20 @@ import { tableGrid } from './grid.js';
|
|
|
46
57
|
*/
|
|
47
58
|
const LAYOUT_RUNS = new WeakMap();
|
|
48
59
|
const WIDEST_WORD = new WeakMap();
|
|
60
|
+
/**
|
|
61
|
+
* A fragment of text that runs on from the text before it on its line, with
|
|
62
|
+
* no place to break between the two — only inline boxes' edges, or a float
|
|
63
|
+
* — and how wide those edges are: a word across a `<span>` with padding is
|
|
64
|
+
* in two fragments, and as wide as both and the padding between. What
|
|
65
|
+
* `joinsBefore` answers, for a min-content bound (`block.ts`'s `wordBound`)
|
|
66
|
+
* that would have the word no wider than either fragment's widest.
|
|
67
|
+
*/
|
|
68
|
+
const JOINS = new WeakMap();
|
|
69
|
+
/** The edges a fragment of text runs on across from the text before it on
|
|
70
|
+
* its line, or undefined where it does not run on from it (`JOINS`). */
|
|
71
|
+
export function joinsBefore(text) {
|
|
72
|
+
return JOINS.get(text);
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* The widest word between spaces in a layout's text: its runs laid out
|
|
51
76
|
* again with every space a line break, at no width limit. Where a text's
|
|
@@ -68,7 +93,10 @@ export function widestWord(fonts, layout, measured = true) {
|
|
|
68
93
|
const words = fonts.layout(held.map((run) => ({
|
|
69
94
|
...run,
|
|
70
95
|
text: run.text.replace(/[ \t\u200b]+/g, '\n'),
|
|
71
|
-
})), {},
|
|
96
|
+
})), {},
|
|
97
|
+
// measured as the lines are fitted, or a word across two elements is
|
|
98
|
+
// read narrower than it fits
|
|
99
|
+
{ fit: PARAGRAPH_FIT });
|
|
72
100
|
widest = 0;
|
|
73
101
|
for (const line of words.lines)
|
|
74
102
|
widest = Math.max(widest, line.width);
|
|
@@ -246,6 +274,8 @@ function linesOf(block, options, items, floatCount) {
|
|
|
246
274
|
CLIPPED_TEXT.add(block);
|
|
247
275
|
fonts = recording(fonts);
|
|
248
276
|
}
|
|
277
|
+
// small capitals the face does not have, made of its capitals
|
|
278
|
+
synthesizeSmallCaps(items, fonts);
|
|
249
279
|
if (wraps(block.style))
|
|
250
280
|
holdNoWrap(items, block);
|
|
251
281
|
// one walk for what few paragraphs have: text that casts a shadow, and a
|
|
@@ -253,12 +283,17 @@ function linesOf(block, options, items, floatCount) {
|
|
|
253
283
|
// searched for one where it cannot be
|
|
254
284
|
let tabbed = false;
|
|
255
285
|
let shadowed = false;
|
|
286
|
+
// and text that keeps its spaces where its lines wrap, whose spaces a
|
|
287
|
+
// line may end on (`hangPreserved`)
|
|
288
|
+
let keepsSpaces = false;
|
|
256
289
|
for (const item of items) {
|
|
257
290
|
if (item.kind !== 'text' || item.control)
|
|
258
291
|
continue;
|
|
259
292
|
const style = item.box.style;
|
|
260
293
|
if (style.textShadow)
|
|
261
294
|
shadowed = true;
|
|
295
|
+
if (style.whiteSpace === 'pre-wrap')
|
|
296
|
+
keepsSpaces = true;
|
|
262
297
|
if (!tabbed &&
|
|
263
298
|
(style.whiteSpace === 'pre' || style.whiteSpace === 'pre-wrap') &&
|
|
264
299
|
item.run.text.includes('\t')) {
|
|
@@ -293,6 +328,8 @@ function linesOf(block, options, items, floatCount) {
|
|
|
293
328
|
firstLineColour(items, options.firstLine);
|
|
294
329
|
const style = block.style;
|
|
295
330
|
const wrapWords = overflowWrapOf(style, items);
|
|
331
|
+
// a line may break between any two letters, an edge between them or not
|
|
332
|
+
const breaksAnywhere = style.wordBreak === 'break-all' || style.lineBreakAnywhere;
|
|
296
333
|
const base = {
|
|
297
334
|
family: style.fontFamily,
|
|
298
335
|
size: style.fontSize,
|
|
@@ -353,23 +390,33 @@ function linesOf(block, options, items, floatCount) {
|
|
|
353
390
|
const spaced = hasEdges && !hasAtomics && !hasOffset && spacersHold(style, items);
|
|
354
391
|
// a first line in fonts of its own is found a piece at a time, in them
|
|
355
392
|
const restyled = options.firstLineStyle !== undefined;
|
|
393
|
+
// and where one layout makes a line shorter than the strut, its text all
|
|
394
|
+
// smaller than the block's, the lines are made a piece at a time too,
|
|
395
|
+
// where every line holds it (`shortOfStrut`)
|
|
396
|
+
const strut = fonts ? strutOf(fonts, style) : null;
|
|
397
|
+
// and the first line holds what it is handed besides: a marker's room
|
|
398
|
+
const firstStrut = withRoom(strut, options.firstStrut);
|
|
399
|
+
let strutted = false;
|
|
356
400
|
// Inline boxes' edges as spacers in one layout, where the engine left
|
|
357
401
|
// every edge on the line of the content it belongs to (`layoutSpaced`);
|
|
358
402
|
// the lines a piece at a time, below, where it did not.
|
|
359
403
|
if (spaced && !floated && !indent && !alignedApart && !restyled) {
|
|
360
|
-
const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts);
|
|
361
|
-
if (laid)
|
|
404
|
+
const laid = layoutSpaced(items, base, style, options.width, lineHeightMul, align, fonts, keepsSpaces);
|
|
405
|
+
if (laid && !shortOfStrut(laid.lines, strut, firstStrut))
|
|
362
406
|
return laid;
|
|
407
|
+
if (laid)
|
|
408
|
+
strutted = true;
|
|
363
409
|
}
|
|
364
410
|
// The text-only, float-free, unindented case: one call, every line — or
|
|
365
411
|
// one call per *chunk*, when the text is long and carries hard breaks.
|
|
366
|
-
if (!hasAtomics &&
|
|
412
|
+
fast: if (!hasAtomics &&
|
|
367
413
|
!hasEdges &&
|
|
368
414
|
!raised &&
|
|
369
415
|
!floated &&
|
|
370
416
|
!indent &&
|
|
371
417
|
!alignedApart &&
|
|
372
|
-
!restyled
|
|
418
|
+
!restyled &&
|
|
419
|
+
!strutted) {
|
|
373
420
|
// no atomics and no edges, so everything is text
|
|
374
421
|
const textItems = items;
|
|
375
422
|
let total = 0;
|
|
@@ -390,12 +437,16 @@ function linesOf(block, options, items, floatCount) {
|
|
|
390
437
|
const perLine = cut !== null && !clamp && hasNewline;
|
|
391
438
|
// an embedding cannot be cut into chunks
|
|
392
439
|
if (perLine && !hasControls(items)) {
|
|
393
|
-
return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut });
|
|
440
|
+
return layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, { chars: Infinity, hardLines: 1, cut }, keepsSpaces);
|
|
394
441
|
}
|
|
395
442
|
if (perLine)
|
|
396
443
|
cut = null;
|
|
397
444
|
if (total > CHUNK_TRIGGER_CHARS && hasNewline && !hasControls(items)) {
|
|
398
|
-
|
|
445
|
+
const chunked = layoutChunked(textItems, base, style, options.width, lineHeightMul, align, fonts, CHUNKS, keepsSpaces);
|
|
446
|
+
if (!shortOfStrut(chunked.lines, strut, firstStrut))
|
|
447
|
+
return chunked;
|
|
448
|
+
strutted = true;
|
|
449
|
+
break fast;
|
|
399
450
|
}
|
|
400
451
|
let runs = [];
|
|
401
452
|
let spans = new SpanMap();
|
|
@@ -414,10 +465,19 @@ function linesOf(block, options, items, floatCount) {
|
|
|
414
465
|
align: justifyRest && !justifyLast ? engineAlign(lastAlign) : align,
|
|
415
466
|
direction: style.direction,
|
|
416
467
|
overflowWrap: cutWrap(style, cut, wrapWords),
|
|
468
|
+
fit: PARAGRAPH_FIT,
|
|
417
469
|
...cut,
|
|
418
470
|
};
|
|
419
471
|
let layout = fonts.layout(runs, base, layoutOptions);
|
|
420
472
|
LAYOUT_RUNS.set(layout, runs);
|
|
473
|
+
const held = keepsSpaces
|
|
474
|
+
? holdAtBreaks(layout, runs, spans, fonts, layoutOptions.maxWidth ?? options.width, !cut)
|
|
475
|
+
: null;
|
|
476
|
+
if (held) {
|
|
477
|
+
runs = held;
|
|
478
|
+
layout = fonts.layout(runs, base, layoutOptions);
|
|
479
|
+
LAYOUT_RUNS.set(layout, runs);
|
|
480
|
+
}
|
|
421
481
|
// A clamp that cut the text short says so, for the clamp point after
|
|
422
482
|
// it, and its last line ends in an ellipsis — as it does where the
|
|
423
483
|
// clamp point falls just after the lines, more of the container after
|
|
@@ -464,11 +524,22 @@ function linesOf(block, options, items, floatCount) {
|
|
|
464
524
|
: balanced
|
|
465
525
|
? unwrappedPlacer(style, options.width, false)
|
|
466
526
|
: null);
|
|
527
|
+
if (keepsSpaces)
|
|
528
|
+
hangLines(lines, 0, runs, fonts, rtl);
|
|
529
|
+
// a clamp and a cut are the one layout's to make
|
|
530
|
+
if (!clamp && !cut && shortOfStrut(lines, strut, firstStrut)) {
|
|
531
|
+
strutted = true;
|
|
532
|
+
break fast;
|
|
533
|
+
}
|
|
467
534
|
return clampCut
|
|
468
535
|
? { lines, height: layout.height, width: widest, cut: true }
|
|
469
536
|
: { lines, height: layout.height, width: widest };
|
|
470
537
|
}
|
|
471
538
|
// --- the general case: line at a time -------------------------------------
|
|
539
|
+
// the paragraph's bidi levels, where anything in it can be reordered: a
|
|
540
|
+
// line is laid out a piece at a time here, and its pieces are ordered by
|
|
541
|
+
// them (`levelPieces`)
|
|
542
|
+
const paraBidi = fonts ? paragraphBidi(block, items, rtl) : null;
|
|
472
543
|
const lines = [];
|
|
473
544
|
let widest = 0;
|
|
474
545
|
let y = 0;
|
|
@@ -509,7 +580,6 @@ function linesOf(block, options, items, floatCount) {
|
|
|
509
580
|
}
|
|
510
581
|
deferred = [];
|
|
511
582
|
};
|
|
512
|
-
const strut = fonts ? strutOf(fonts, style) : null;
|
|
513
583
|
// How tall a line is taken to be before it is made, for its room beside
|
|
514
584
|
// the floats: the paragraph's strut, a line of its text. An item taller
|
|
515
585
|
// asks over its own height, and the line made over its whole (`close`).
|
|
@@ -530,6 +600,7 @@ function linesOf(block, options, items, floatCount) {
|
|
|
530
600
|
align: 'left',
|
|
531
601
|
direction: style.direction,
|
|
532
602
|
overflowWrap: wrapWords,
|
|
603
|
+
fit: PARAGRAPH_FIT,
|
|
533
604
|
},
|
|
534
605
|
}
|
|
535
606
|
: null;
|
|
@@ -541,14 +612,24 @@ function linesOf(block, options, items, floatCount) {
|
|
|
541
612
|
* which `text-align-last` aligns. */
|
|
542
613
|
const close = (last = false) => {
|
|
543
614
|
const onFirst = lineOne !== null && !lines.length;
|
|
615
|
+
const levels = paraBidi
|
|
616
|
+
? {
|
|
617
|
+
bidi: paraBidi,
|
|
618
|
+
fonts: fonts,
|
|
619
|
+
base: onFirst ? lineOne.base : base,
|
|
620
|
+
lineHeight: onFirst ? lineOne.lineHeight : lineHeightMul,
|
|
621
|
+
}
|
|
622
|
+
: null;
|
|
544
623
|
const line = finishLine(open, y,
|
|
545
624
|
// the room beside the floats over the whole line box, which an
|
|
546
625
|
// inline-block can make taller than its text (CSS 2.1 9.5)
|
|
547
|
-
(height) => bandAt(options, y, Math.max(height, guess)), last || !wraps(style) ? lastShift : restShift, rtl,
|
|
626
|
+
(height) => bandAt(options, y, Math.max(height, guess)), last || !wraps(style) ? lastShift : restShift, rtl, lines.length
|
|
627
|
+
? strut
|
|
628
|
+
: withRoom(onFirst ? lineOne.strut : strut, options.firstStrut), onFirst ? lineOne.lifts : lifts, (last || !wraps(style) ? justify.last : justify.rest)
|
|
548
629
|
? onFirst
|
|
549
630
|
? lineOne.justify
|
|
550
631
|
: lineJustify
|
|
551
|
-
: null);
|
|
632
|
+
: null, levels);
|
|
552
633
|
if (!line) {
|
|
553
634
|
open = openLine(0);
|
|
554
635
|
placeDeferred();
|
|
@@ -668,7 +749,12 @@ function linesOf(block, options, items, floatCount) {
|
|
|
668
749
|
if (raise)
|
|
669
750
|
placed.raise = raise;
|
|
670
751
|
open.atomics.push(placed);
|
|
671
|
-
open.order.push({
|
|
752
|
+
open.order.push({
|
|
753
|
+
kind: 'atomic',
|
|
754
|
+
at: open.x,
|
|
755
|
+
item: placed,
|
|
756
|
+
...(paraBidi ? { para: paraBidi.starts[index] } : null),
|
|
757
|
+
});
|
|
672
758
|
open.x += outer;
|
|
673
759
|
open.hang = 0;
|
|
674
760
|
open.left = room.left;
|
|
@@ -702,13 +788,17 @@ function linesOf(block, options, items, floatCount) {
|
|
|
702
788
|
// `text-align-last` sets it apart)
|
|
703
789
|
const tailIsPlain = !alignedApart &&
|
|
704
790
|
!onFirst &&
|
|
791
|
+
!strutted &&
|
|
705
792
|
segment.nextIndex >= items.length &&
|
|
706
793
|
open.x === 0 &&
|
|
707
794
|
!open.atomics.length &&
|
|
708
795
|
!open.edges.length &&
|
|
709
796
|
!pending.length &&
|
|
710
797
|
!deferred.length &&
|
|
711
|
-
!(options.floats?.intersects(options.startY + y, Infinity) ?? false)
|
|
798
|
+
!(options.floats?.intersects(options.startY + y, Infinity) ?? false) &&
|
|
799
|
+
// and ordered alone as the paragraph orders it
|
|
800
|
+
(!paraBidi ||
|
|
801
|
+
orderedAlone(paraBidi, segment, paraBidi.starts[index] + offset, rtl));
|
|
712
802
|
if (tailIsPlain) {
|
|
713
803
|
const tailOptions = {
|
|
714
804
|
maxWidth: wraps(style) ? Math.max(1, available) : undefined,
|
|
@@ -716,42 +806,63 @@ function linesOf(block, options, items, floatCount) {
|
|
|
716
806
|
align,
|
|
717
807
|
direction: style.direction,
|
|
718
808
|
overflowWrap: wrapWords,
|
|
809
|
+
fit: PARAGRAPH_FIT,
|
|
719
810
|
};
|
|
720
|
-
let
|
|
721
|
-
|
|
811
|
+
let tailRuns = segment.runs;
|
|
812
|
+
let layout = fonts.layout(tailRuns, base, tailOptions);
|
|
813
|
+
LAYOUT_RUNS.set(layout, tailRuns);
|
|
814
|
+
const held = keepsSpaces
|
|
815
|
+
? holdAtBreaks(layout, tailRuns, segment.spans, fonts, tailOptions.maxWidth ?? options.width, true)
|
|
816
|
+
: null;
|
|
817
|
+
if (held) {
|
|
818
|
+
tailRuns = held;
|
|
819
|
+
layout = fonts.layout(tailRuns, base, tailOptions);
|
|
820
|
+
LAYOUT_RUNS.set(layout, tailRuns);
|
|
821
|
+
}
|
|
722
822
|
// the lines past the floats are justified as the lines beside them
|
|
723
823
|
// were (`finishLine`), in the room they have
|
|
724
|
-
layout = justifiedLayout(fonts,
|
|
824
|
+
layout = justifiedLayout(fonts, tailRuns, base, tailOptions, layout, Math.max(1, available), justify).layout;
|
|
825
|
+
const tail = [];
|
|
826
|
+
const lift = layoutLift(layout);
|
|
725
827
|
for (let i = 0; i < layout.lines.length; i += 1) {
|
|
726
828
|
const natural = layout.lines[i];
|
|
727
829
|
const text = {
|
|
728
830
|
layout,
|
|
729
831
|
layoutLine: i,
|
|
730
832
|
drawX: band.left,
|
|
731
|
-
drawY: y,
|
|
833
|
+
drawY: y - lift,
|
|
732
834
|
textStart: segment.spans.documentAt(natural.start),
|
|
733
835
|
textEnd: segment.spans.documentAt(natural.end),
|
|
734
836
|
layoutStart: natural.start,
|
|
735
837
|
spans: segment.spans,
|
|
736
838
|
};
|
|
737
839
|
segment.spans.giveGaps(text, natural.start, natural.end);
|
|
738
|
-
|
|
840
|
+
tail.push({
|
|
739
841
|
x: band.left + natural.x,
|
|
740
842
|
y: y + natural.y,
|
|
741
843
|
width: natural.width,
|
|
742
844
|
height: natural.height,
|
|
743
|
-
baseline: natural.baseline - natural.y,
|
|
845
|
+
baseline: natural.baseline - natural.y - lift,
|
|
744
846
|
texts: [text],
|
|
745
847
|
textStart: text.textStart,
|
|
746
848
|
textEnd: text.textEnd,
|
|
747
849
|
atomics: [],
|
|
748
850
|
});
|
|
749
|
-
widest = Math.max(widest, natural.width);
|
|
750
851
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
852
|
+
if (keepsSpaces)
|
|
853
|
+
hangLines(tail, 0, tailRuns, fonts, rtl);
|
|
854
|
+
// or a line at a time, where one of them is shorter than the strut
|
|
855
|
+
if (!shortOfStrut(tail, strut, lines.length ? null : firstStrut)) {
|
|
856
|
+
for (const line of tail) {
|
|
857
|
+
lines.push(line);
|
|
858
|
+
widest = Math.max(widest, line.width);
|
|
859
|
+
}
|
|
860
|
+
y += layout.height;
|
|
861
|
+
index = segment.nextIndex;
|
|
862
|
+
offset = 0;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
strutted = true;
|
|
755
866
|
}
|
|
756
867
|
// One line: `maxLines: 1` cuts the layout at the first break and its
|
|
757
868
|
// `truncated` flag answers "did the segment wrap" — so a line costs one
|
|
@@ -770,27 +881,72 @@ function linesOf(block, options, items, floatCount) {
|
|
|
770
881
|
!BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text)
|
|
771
882
|
? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
|
|
772
883
|
: 0;
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
// Never aligned by the text layout: the alignment belongs to the whole
|
|
777
|
-
// line — its text, its atomics and its inline boxes' edges together —
|
|
778
|
-
// which only this loop can see, and `finishLine` shifts it all. Laid
|
|
779
|
-
// out aligned, the first fragment of a centred line was centred alone
|
|
780
|
-
// and whatever followed it on the line was placed as though it had
|
|
781
|
-
// not been, over it.
|
|
782
|
-
align: 'left',
|
|
783
|
-
direction: style.direction,
|
|
784
|
-
overflowWrap: wrapWords,
|
|
785
|
-
maxLines: 1,
|
|
786
|
-
});
|
|
787
|
-
LAYOUT_RUNS.set(fragment, segment.runs);
|
|
788
|
-
const first = fragment.lines[0];
|
|
884
|
+
const lineHeight = onFirst ? lineOne.lineHeight : lineHeightMul;
|
|
885
|
+
let fragment = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, room - tied);
|
|
886
|
+
let first = fragment.lines[0];
|
|
789
887
|
if (!first) {
|
|
790
888
|
index = segment.nextIndex;
|
|
791
889
|
offset = 0;
|
|
792
890
|
continue;
|
|
793
891
|
}
|
|
892
|
+
// Did the segment wrap? ntk answers with `truncated`. A layout that does
|
|
893
|
+
// not carry the flag — react-x11's Cocoa engine reports none — is asked
|
|
894
|
+
// the same of its line ends instead: it wrapped if anything but
|
|
895
|
+
// whitespace follows the first line. Read as "fitted", a cut fragment
|
|
896
|
+
// advanced past the whole segment, and a paragraph beside a float lost
|
|
897
|
+
// every line after its first. And a line that ends at a `<br>` is over
|
|
898
|
+
// whether or not anything followed it in this segment: what comes next
|
|
899
|
+
// — an atomic, an element's edge — is the next line's.
|
|
900
|
+
let wrapped = breakBefore(segment.runs, first.end) ||
|
|
901
|
+
(fragment.truncated ?? inkBeyond(segment.runs, first.end));
|
|
902
|
+
// An inline box's edge is no place to break a line (CSS Text 3, 5.1):
|
|
903
|
+
// where the segment runs to its end on this line, its last word runs on
|
|
904
|
+
// past it, through the edges after it, into the text they stand before
|
|
905
|
+
// — `ab<span style="padding: 0 4px">cd</span>` is one word, as it is
|
|
906
|
+
// without the padding. Where the line may break at that text after all,
|
|
907
|
+
// the edges that close boxes are still the word's, as a box's end goes
|
|
908
|
+
// with the content it closes, and it is the ones that open a box that
|
|
909
|
+
// go with what follows. The line holds the word and all of that or none
|
|
910
|
+
// of it, so where it does not fit, the line breaks at the last place in
|
|
911
|
+
// the segment it may break, and the word goes to the next line whole.
|
|
912
|
+
// Where the segment has no such place, the word is the line's first,
|
|
913
|
+
// and is measured with what it runs into (below). A word that ends in
|
|
914
|
+
// white space hangs it, and its edges after it with it.
|
|
915
|
+
let glued = 0;
|
|
916
|
+
if (!wrapped &&
|
|
917
|
+
wraps(style) &&
|
|
918
|
+
lineItems[segment.nextIndex]?.kind === 'edge' &&
|
|
919
|
+
!BREAKS_AFTER.test(segment.runs[segment.runs.length - 1].text) &&
|
|
920
|
+
// none of it matters where the most it could come to fits: most
|
|
921
|
+
// edges are nowhere near a line's end
|
|
922
|
+
first.width + unbreakableBound(lineItems, segment.nextIndex) >
|
|
923
|
+
room + FIT_SLACK) {
|
|
924
|
+
const after = textAfterEdges(lineItems, segment.nextIndex);
|
|
925
|
+
glued =
|
|
926
|
+
after &&
|
|
927
|
+
!breaksBetween(fonts, lineBase, segment.runs, runsLength(segment.runs), after, breaksAnywhere)
|
|
928
|
+
? unbreakableAfter(lineItems, segment.nextIndex, style, fonts, lineBase)
|
|
929
|
+
: closingEdges(lineItems, segment.nextIndex);
|
|
930
|
+
}
|
|
931
|
+
if (glued > 0 && first.width + glued > room + FIT_SLACK) {
|
|
932
|
+
// the last place it may break: laid out a hair narrower than it is,
|
|
933
|
+
// which the rest of the segment fits, not in the room the word
|
|
934
|
+
// leaves, where it broke after its first word if the word and what
|
|
935
|
+
// it runs into are wider than the line
|
|
936
|
+
const earlier = fragmentLayout(fonts, segment.runs, lineBase, style, lineHeight, wrapWords, first.width - 0.5);
|
|
937
|
+
const line = earlier.lines[0];
|
|
938
|
+
// and not inside a word that `overflow-wrap` cut to fit: that is for
|
|
939
|
+
// a word no line holds, and the word and what it runs into may fit
|
|
940
|
+
// the next line
|
|
941
|
+
if (line && line.end < first.end && !insideWord(segment.runs, line.end)) {
|
|
942
|
+
fragment = earlier;
|
|
943
|
+
first = line;
|
|
944
|
+
wrapped = true;
|
|
945
|
+
glued = 0;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// the first word, with what it runs into past the segment
|
|
949
|
+
const word = first.width + glued;
|
|
794
950
|
// Nothing fits beside the floats — the first word is wider than the
|
|
795
951
|
// room they leave, and was either run past it or cut inside itself to
|
|
796
952
|
// fit — so the line moves down to where a float ends, and tries again
|
|
@@ -799,7 +955,7 @@ function linesOf(block, options, items, floatCount) {
|
|
|
799
955
|
// is `overflow-wrap`'s to break or to let run past.
|
|
800
956
|
if (isEmpty(open) &&
|
|
801
957
|
available < options.width &&
|
|
802
|
-
tooNarrow(segment.runs, first.end,
|
|
958
|
+
tooNarrow(segment.runs, first.end, word, room)) {
|
|
803
959
|
const below = belowFloats(options, y, guess);
|
|
804
960
|
if (below !== null) {
|
|
805
961
|
y = below;
|
|
@@ -808,27 +964,37 @@ function linesOf(block, options, items, floatCount) {
|
|
|
808
964
|
}
|
|
809
965
|
// An inline box's opening edge goes with the first word after it: where
|
|
810
966
|
// the word does not fit the room the edge leaves, both go to the next
|
|
811
|
-
// line, rather than the edge being left at this one's end
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
967
|
+
// line, rather than the edge being left at this one's end — where the
|
|
968
|
+
// line may break there at all. The edge is no place to break (CSS Text
|
|
969
|
+
// 3, 5.1), so that is where the line may break between what it ends on
|
|
970
|
+
// and the word: after white space or an atomic, or where the text
|
|
971
|
+
// engine breaks the two run together (`breaksBetween`). `ab<span
|
|
972
|
+
// style="padding: 0 4px">cd</span>` is one word, and a line too narrow
|
|
973
|
+
// for it runs past its end, as it does for the word without the
|
|
974
|
+
// padding; broken before the span, a float's min-content width was a
|
|
975
|
+
// letter wide, and it came out as wide as its room.
|
|
976
|
+
// And a word that may start a line — after a space the line ends on,
|
|
977
|
+
// or after an atomic, which has a break after it (CSS Text 3, 5.1) —
|
|
978
|
+
// goes to the next line whole where it does not fit what is left of
|
|
979
|
+
// this one, rather than being broken inside itself to fit it: the text
|
|
980
|
+
// after an inline-block, or after a float it was cut at, was its first
|
|
981
|
+
// letter at the line's end and the rest on the next.
|
|
818
982
|
const startsLine = wraps(style) &&
|
|
819
983
|
(open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic');
|
|
820
984
|
if (!isEmpty(open) &&
|
|
821
985
|
(pendingWidth > 0
|
|
822
|
-
? tooNarrow(segment.runs, first.end,
|
|
986
|
+
? tooNarrow(segment.runs, first.end, word, room) &&
|
|
987
|
+
breaksAfterLine(open, style, fonts, lineBase, segment.runs, breaksAnywhere)
|
|
823
988
|
: startsLine &&
|
|
824
989
|
// run past the room — its white space is no part of the width —
|
|
825
990
|
// or cut inside itself to fit it
|
|
826
|
-
(
|
|
827
|
-
tooNarrow(segment.runs, first.end,
|
|
991
|
+
(word > room + FIT_SLACK ||
|
|
992
|
+
tooNarrow(segment.runs, first.end, word, room)))) {
|
|
828
993
|
close();
|
|
829
994
|
continue;
|
|
830
995
|
}
|
|
831
996
|
placePending(band.left);
|
|
997
|
+
const joins = joinsOnto(open, segment.runs, first.start);
|
|
832
998
|
const placed = {
|
|
833
999
|
layout: fragment,
|
|
834
1000
|
layoutLine: 0,
|
|
@@ -841,26 +1007,35 @@ function linesOf(block, options, items, floatCount) {
|
|
|
841
1007
|
spans: segment.spans,
|
|
842
1008
|
};
|
|
843
1009
|
segment.spans.giveGaps(placed, first.start, first.end);
|
|
1010
|
+
if (joins !== undefined)
|
|
1011
|
+
JOINS.set(placed, joins);
|
|
844
1012
|
open.texts.push(placed);
|
|
845
1013
|
open.order.push({
|
|
846
1014
|
kind: 'text',
|
|
847
1015
|
at: open.x,
|
|
848
1016
|
item: placed,
|
|
849
1017
|
reads: readingOf(segment.runs, first.start, first.end),
|
|
1018
|
+
...(paraBidi
|
|
1019
|
+
? {
|
|
1020
|
+
from: {
|
|
1021
|
+
para: paraBidi.starts[index] + offset,
|
|
1022
|
+
runs: segment.runs,
|
|
1023
|
+
spans: segment.spans,
|
|
1024
|
+
},
|
|
1025
|
+
}
|
|
1026
|
+
: null),
|
|
850
1027
|
});
|
|
851
1028
|
open.x += first.width;
|
|
852
1029
|
open.left = band.left;
|
|
853
1030
|
open.hang = 0;
|
|
854
|
-
//
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
const wrapped = breakBefore(segment.runs, first.end) ||
|
|
863
|
-
(fragment.truncated ?? inkBeyond(segment.runs, first.end));
|
|
1031
|
+
// the spaces `pre-wrap` keeps that it ends on, which the engine hung
|
|
1032
|
+
const hung = keepsSpaces
|
|
1033
|
+
? hangPreserved(placed, segment.runs, runStarts(segment.runs), fonts, rtl)
|
|
1034
|
+
: NOTHING_HUNG;
|
|
1035
|
+
/** Before a forced break, the ones that fit take room (4.1.3). */
|
|
1036
|
+
const holdHung = () => {
|
|
1037
|
+
open.x += Math.min(hung.total, Math.max(0, available - open.x));
|
|
1038
|
+
};
|
|
864
1039
|
if (!wrapped) {
|
|
865
1040
|
// It fitted: the cursor stays on this line for whatever comes next.
|
|
866
1041
|
//
|
|
@@ -874,7 +1049,11 @@ function linesOf(block, options, items, floatCount) {
|
|
|
874
1049
|
const hung = hungSpaces(fonts, segment.runs[segment.runs.length - 1]);
|
|
875
1050
|
let trailing = '';
|
|
876
1051
|
for (let i = segment.runs.length - 1; i >= 0; i -= 1) {
|
|
877
|
-
|
|
1052
|
+
// through the bidi controls after them, which take no room: the
|
|
1053
|
+
// engine strips the spaces before one as well
|
|
1054
|
+
const text = segment.runs[i].text.replace(ENDING_CONTROLS, '');
|
|
1055
|
+
if (!text)
|
|
1056
|
+
continue;
|
|
878
1057
|
const m = hung.exec(text);
|
|
879
1058
|
if (!m)
|
|
880
1059
|
break;
|
|
@@ -884,18 +1063,29 @@ function linesOf(block, options, items, floatCount) {
|
|
|
884
1063
|
}
|
|
885
1064
|
if (trailing) {
|
|
886
1065
|
// and noted: if what follows goes to the next line after all,
|
|
887
|
-
// these spaces end this one, where CSS removes them (16.6.1)
|
|
1066
|
+
// these spaces end this one, where CSS removes them (16.6.1).
|
|
1067
|
+
// As wide as the engine says they make its line where the line
|
|
1068
|
+
// goes on, which a layout that fits as a browser does rounds with
|
|
1069
|
+
// the text they end, once (`advance`): the text rounded up to a
|
|
1070
|
+
// 64th and the spaces added after it came to more
|
|
888
1071
|
open.hang =
|
|
889
|
-
|
|
890
|
-
|
|
1072
|
+
first.advance !== undefined
|
|
1073
|
+
? first.advance - first.width
|
|
1074
|
+
: spaceAdvance(fonts, segment.runs[segment.runs.length - 1]) *
|
|
1075
|
+
trailing.length;
|
|
891
1076
|
open.x += open.hang;
|
|
892
1077
|
}
|
|
893
1078
|
}
|
|
1079
|
+
else if (hung.total)
|
|
1080
|
+
holdHung();
|
|
894
1081
|
index = segment.nextIndex;
|
|
895
1082
|
offset = 0;
|
|
896
1083
|
continue;
|
|
897
1084
|
}
|
|
898
|
-
|
|
1085
|
+
const forced = breakBefore(segment.runs, first.end);
|
|
1086
|
+
if (forced && hung.total)
|
|
1087
|
+
holdHung();
|
|
1088
|
+
close(forced);
|
|
899
1089
|
const advanced = advance(items, index, offset, first.end);
|
|
900
1090
|
index = advanced.index;
|
|
901
1091
|
offset = advanced.offset;
|
|
@@ -912,6 +1102,27 @@ function linesOf(block, options, items, floatCount) {
|
|
|
912
1102
|
return { lines, height: y, width: widest, floatRow };
|
|
913
1103
|
}
|
|
914
1104
|
const EMPTY = { lines: [], height: 0, width: 0 };
|
|
1105
|
+
/** The first line of a segment's text in a width, as the line at a time
|
|
1106
|
+
* lays one out (`linesOf`). */
|
|
1107
|
+
function fragmentLayout(fonts, runs, base, style, lineHeight, overflowWrap, width) {
|
|
1108
|
+
const laid = fonts.layout(runs, base, {
|
|
1109
|
+
maxWidth: wraps(style) ? Math.max(1, width) : undefined,
|
|
1110
|
+
lineHeight,
|
|
1111
|
+
// Never aligned by the text layout: the alignment belongs to the whole
|
|
1112
|
+
// line — its text, its atomics and its inline boxes' edges together —
|
|
1113
|
+
// which only this loop can see, and `finishLine` shifts it all. Laid
|
|
1114
|
+
// out aligned, the first fragment of a centred line was centred alone
|
|
1115
|
+
// and whatever followed it on the line was placed as though it had not
|
|
1116
|
+
// been, over it.
|
|
1117
|
+
align: 'left',
|
|
1118
|
+
direction: style.direction,
|
|
1119
|
+
overflowWrap,
|
|
1120
|
+
fit: PARAGRAPH_FIT,
|
|
1121
|
+
maxLines: 1,
|
|
1122
|
+
});
|
|
1123
|
+
LAYOUT_RUNS.set(laid, runs);
|
|
1124
|
+
return laid;
|
|
1125
|
+
}
|
|
915
1126
|
/**
|
|
916
1127
|
* Which line each float a paragraph placed stands beside: the one whose top
|
|
917
1128
|
* it went at, whose content moved over for it — added up on the line, for
|
|
@@ -1019,9 +1230,9 @@ function spacersHold(style, items) {
|
|
|
1019
1230
|
* (UAX #14, LB12a), which is what a spacer is. The lines a piece at a time
|
|
1020
1231
|
* keep it there.
|
|
1021
1232
|
*/
|
|
1022
|
-
function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
|
|
1233
|
+
function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts, keepsSpaces) {
|
|
1023
1234
|
const wrapWords = overflowWrapOf(style, items);
|
|
1024
|
-
|
|
1235
|
+
let runs = [];
|
|
1025
1236
|
const spans = new SpanMap();
|
|
1026
1237
|
const spacers = [];
|
|
1027
1238
|
let doc = 0;
|
|
@@ -1050,12 +1261,22 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
|
|
|
1050
1261
|
align,
|
|
1051
1262
|
direction: style.direction,
|
|
1052
1263
|
overflowWrap: wrapWords,
|
|
1264
|
+
fit: PARAGRAPH_FIT,
|
|
1053
1265
|
};
|
|
1054
1266
|
let layout = fonts.layout(runs, base, layoutOptions);
|
|
1055
1267
|
LAYOUT_RUNS.set(layout, runs);
|
|
1268
|
+
const held = keepsSpaces
|
|
1269
|
+
? holdAtBreaks(layout, runs, spans, fonts, width, true)
|
|
1270
|
+
: null;
|
|
1271
|
+
if (held) {
|
|
1272
|
+
runs = held;
|
|
1273
|
+
layout = fonts.layout(runs, base, layoutOptions);
|
|
1274
|
+
LAYOUT_RUNS.set(layout, runs);
|
|
1275
|
+
}
|
|
1056
1276
|
// justified with its spacers in it, which are edges and not spaces
|
|
1057
1277
|
layout = justifiedLayout(fonts, runs, base, layoutOptions, layout, width, justification(style), new Set(spacers.map((spacer) => spacer.at))).layout;
|
|
1058
1278
|
const place = wraps(style) ? null : unwrappedPlacer(style, width);
|
|
1279
|
+
const lift = layoutLift(layout);
|
|
1059
1280
|
let offsets = null;
|
|
1060
1281
|
const lines = [];
|
|
1061
1282
|
let widest = 0;
|
|
@@ -1086,7 +1307,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
|
|
|
1086
1307
|
layout,
|
|
1087
1308
|
layoutLine: i,
|
|
1088
1309
|
drawX: dx,
|
|
1089
|
-
drawY:
|
|
1310
|
+
drawY: -lift,
|
|
1090
1311
|
textStart: spans.documentAt(natural.start),
|
|
1091
1312
|
textEnd: spans.documentAt(natural.end),
|
|
1092
1313
|
layoutStart: natural.start,
|
|
@@ -1098,7 +1319,7 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
|
|
|
1098
1319
|
y: natural.y,
|
|
1099
1320
|
width: natural.width,
|
|
1100
1321
|
height: natural.height,
|
|
1101
|
-
baseline: natural.baseline - natural.y,
|
|
1322
|
+
baseline: natural.baseline - natural.y - lift,
|
|
1102
1323
|
texts: [text],
|
|
1103
1324
|
textStart: text.textStart,
|
|
1104
1325
|
textEnd: text.textEnd,
|
|
@@ -1107,6 +1328,8 @@ function layoutSpaced(items, base, style, width, lineHeightMul, align, fonts) {
|
|
|
1107
1328
|
});
|
|
1108
1329
|
widest = Math.max(widest, natural.width);
|
|
1109
1330
|
}
|
|
1331
|
+
if (keepsSpaces)
|
|
1332
|
+
hangLines(lines, 0, runs, fonts, style.direction === 'rtl');
|
|
1110
1333
|
return { lines, height: layout.height, width: widest };
|
|
1111
1334
|
}
|
|
1112
1335
|
/** The code point a code-unit offset into a layout's text starts. */
|
|
@@ -1151,6 +1374,7 @@ export function layoutOffsetOf(text, doc, end = false) {
|
|
|
1151
1374
|
* `place` says; returns the widest. */
|
|
1152
1375
|
function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
|
|
1153
1376
|
let widest = 0;
|
|
1377
|
+
const lift = layoutLift(layout);
|
|
1154
1378
|
for (let i = 0; i < layout.lines.length; i += 1) {
|
|
1155
1379
|
const natural = layout.lines[i];
|
|
1156
1380
|
const dx = place ? place(natural) : 0;
|
|
@@ -1158,7 +1382,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
|
|
|
1158
1382
|
layout,
|
|
1159
1383
|
layoutLine: i,
|
|
1160
1384
|
drawX: xOff + dx,
|
|
1161
|
-
drawY: yOff,
|
|
1385
|
+
drawY: yOff - lift,
|
|
1162
1386
|
textStart: spans.documentAt(natural.start),
|
|
1163
1387
|
textEnd: spans.documentAt(natural.end),
|
|
1164
1388
|
layoutStart: natural.start,
|
|
@@ -1170,7 +1394,7 @@ function emitLayout(layout, spans, xOff, yOff, lines, place = null) {
|
|
|
1170
1394
|
y: yOff + natural.y,
|
|
1171
1395
|
width: natural.width,
|
|
1172
1396
|
height: natural.height,
|
|
1173
|
-
baseline: natural.baseline - natural.y,
|
|
1397
|
+
baseline: natural.baseline - natural.y - lift,
|
|
1174
1398
|
texts: [text],
|
|
1175
1399
|
textStart: text.textStart,
|
|
1176
1400
|
textEnd: text.textEnd,
|
|
@@ -1206,7 +1430,7 @@ const CHUNKS = {
|
|
|
1206
1430
|
* breaking never crosses a required break), so the chunk seams are
|
|
1207
1431
|
* invisible; each chunk stays a single batch a viewport can keep whole.
|
|
1208
1432
|
*/
|
|
1209
|
-
function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS) {
|
|
1433
|
+
function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, chunking = CHUNKS, keepsSpaces = false) {
|
|
1210
1434
|
const wrapWords = overflowWrapOf(style, items);
|
|
1211
1435
|
const lines = [];
|
|
1212
1436
|
let widest = 0;
|
|
@@ -1217,7 +1441,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
|
|
|
1217
1441
|
let hardLines = 0;
|
|
1218
1442
|
const { cut } = chunking;
|
|
1219
1443
|
const place = wraps(style) || cut ? null : unwrappedPlacer(style, width);
|
|
1220
|
-
const flush = () => {
|
|
1444
|
+
const flush = (last = false) => {
|
|
1221
1445
|
if (!chunkRuns.length)
|
|
1222
1446
|
return;
|
|
1223
1447
|
const chunkOptions = {
|
|
@@ -1226,14 +1450,27 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
|
|
|
1226
1450
|
align,
|
|
1227
1451
|
direction: style.direction,
|
|
1228
1452
|
overflowWrap: cutWrap(style, cut, wrapWords),
|
|
1453
|
+
fit: PARAGRAPH_FIT,
|
|
1229
1454
|
...cut,
|
|
1230
1455
|
};
|
|
1231
1456
|
let layout = fonts.layout(chunkRuns, base, chunkOptions);
|
|
1232
1457
|
LAYOUT_RUNS.set(layout, chunkRuns);
|
|
1458
|
+
const held = keepsSpaces
|
|
1459
|
+
? holdAtBreaks(layout, chunkRuns, chunkSpans, fonts, chunkOptions.maxWidth ?? width, last && !cut)
|
|
1460
|
+
: null;
|
|
1461
|
+
if (held) {
|
|
1462
|
+
chunkRuns = held;
|
|
1463
|
+
layout = fonts.layout(chunkRuns, base, chunkOptions);
|
|
1464
|
+
LAYOUT_RUNS.set(layout, chunkRuns);
|
|
1465
|
+
}
|
|
1233
1466
|
// a chunk ends at a forced break or at the paragraph's end, which is
|
|
1234
1467
|
// where `justifiedRuns` takes a chunk's last line to end
|
|
1235
1468
|
layout = justifiedLayout(fonts, chunkRuns, base, chunkOptions, layout, width, justification(style)).layout;
|
|
1469
|
+
const from = lines.length;
|
|
1236
1470
|
widest = Math.max(widest, emitLayout(layout, chunkSpans, 0, y, lines, place));
|
|
1471
|
+
if (keepsSpaces) {
|
|
1472
|
+
hangLines(lines, from, chunkRuns, fonts, style.direction === 'rtl');
|
|
1473
|
+
}
|
|
1237
1474
|
y += layout.height;
|
|
1238
1475
|
chunkRuns = [];
|
|
1239
1476
|
chunkSpans = new SpanMap();
|
|
@@ -1293,7 +1530,7 @@ function layoutChunked(items, base, style, width, lineHeightMul, align, fonts, c
|
|
|
1293
1530
|
text = text.slice(cut);
|
|
1294
1531
|
}
|
|
1295
1532
|
}
|
|
1296
|
-
flush();
|
|
1533
|
+
flush(true);
|
|
1297
1534
|
return { lines, height: y, width: widest };
|
|
1298
1535
|
}
|
|
1299
1536
|
function openLine(indent) {
|
|
@@ -1314,6 +1551,250 @@ function breaksAtEnd(open, style) {
|
|
|
1314
1551
|
return (wraps(style) &&
|
|
1315
1552
|
(open.hang > 0 || open.order[open.order.length - 1]?.kind === 'atomic'));
|
|
1316
1553
|
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Whether a line may break before an inline box's opening edge, between
|
|
1556
|
+
* what the line has got to and `next`, the text after the edge: after an
|
|
1557
|
+
* atomic, or after the white space the line ends on, as `breaksAtEnd`
|
|
1558
|
+
* says, or where the text engine breaks the line's last text and `next`
|
|
1559
|
+
* run together (`breaksBetween`). An edge on the way is no place to break
|
|
1560
|
+
* of its own (CSS Text 3, 5.1), and a line with no text or atomic on it
|
|
1561
|
+
* yet has none to hold on to.
|
|
1562
|
+
*/
|
|
1563
|
+
function breaksAfterLine(open, style, fonts, base, next, anywhere) {
|
|
1564
|
+
if (!wraps(style))
|
|
1565
|
+
return false;
|
|
1566
|
+
if (open.hang > 0)
|
|
1567
|
+
return true;
|
|
1568
|
+
for (let i = open.order.length - 1; i >= 0; i -= 1) {
|
|
1569
|
+
const placed = open.order[i];
|
|
1570
|
+
if (placed.kind === 'edge')
|
|
1571
|
+
continue;
|
|
1572
|
+
if (placed.kind === 'atomic')
|
|
1573
|
+
return true;
|
|
1574
|
+
const text = placed.item;
|
|
1575
|
+
const runs = LAYOUT_RUNS.get(text.layout);
|
|
1576
|
+
if (!runs || (runs.length > 0 && typeof runs[0] === 'number'))
|
|
1577
|
+
return true;
|
|
1578
|
+
const end = text.layout.lines[text.layoutLine].end;
|
|
1579
|
+
return breaksBetween(fonts, base, runs, end, next, anywhere);
|
|
1580
|
+
}
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* How wide the edges are between the text the open line ends on and text
|
|
1585
|
+
* starting at `start` in `runs`, where the one runs on into the other
|
|
1586
|
+
* (`JOINS`): neither is white space at the join, and no atomic, which has a
|
|
1587
|
+
* break before and after it, is between them. Undefined where they do not
|
|
1588
|
+
* join, or the line has no text yet. Letters on either side are taken to
|
|
1589
|
+
* join whatever they are, which may count an ideograph's join that the
|
|
1590
|
+
* engine would break at: it makes the bound it is for a larger one, never a
|
|
1591
|
+
* smaller.
|
|
1592
|
+
*/
|
|
1593
|
+
function joinsOnto(open, runs, start) {
|
|
1594
|
+
if (open.hang > 0)
|
|
1595
|
+
return undefined;
|
|
1596
|
+
const next = charAt(runs, start);
|
|
1597
|
+
if (next === '' || SPACE.test(next))
|
|
1598
|
+
return undefined;
|
|
1599
|
+
let edges = 0;
|
|
1600
|
+
for (let i = open.order.length - 1; i >= 0; i -= 1) {
|
|
1601
|
+
const placed = open.order[i];
|
|
1602
|
+
if (placed.kind === 'edge') {
|
|
1603
|
+
edges += placed.item.width;
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
if (placed.kind === 'atomic')
|
|
1607
|
+
return undefined;
|
|
1608
|
+
const text = placed.item;
|
|
1609
|
+
const before = LAYOUT_RUNS.get(text.layout);
|
|
1610
|
+
if (!before || (before.length > 0 && typeof before[0] === 'number')) {
|
|
1611
|
+
return undefined;
|
|
1612
|
+
}
|
|
1613
|
+
const last = charAt(before, text.layout.lines[text.layoutLine].end - 1);
|
|
1614
|
+
return last === '' || SPACE.test(last) ? undefined : edges;
|
|
1615
|
+
}
|
|
1616
|
+
return undefined;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Whether a line may break between two texts an inline box's edge stands
|
|
1620
|
+
* between — the text of `before` up to the code unit `end`, and `after` —
|
|
1621
|
+
* which is where it may break them run together, the edge being no place
|
|
1622
|
+
* to break (CSS Text 3, 5.1). After white space, and not before it, where
|
|
1623
|
+
* the break is after that white space instead (UAX #14, LB7). Never between
|
|
1624
|
+
* two letters or digits of the alphabets whose words break only at spaces,
|
|
1625
|
+
* which is every edge in running text, and needs no asking. Anything else
|
|
1626
|
+
* — a hyphen, a dash, an ideograph or kana, a bracket, a slash — is asked
|
|
1627
|
+
* of the text engine: the two words, laid out at no width, break at every
|
|
1628
|
+
* place a line may, which is UAX #14 as the `linebreak` package has it on
|
|
1629
|
+
* both backends — ntk breaks every line with it, and react-x11's CoreText
|
|
1630
|
+
* engine a text at no width. So an edge after a hyphen, or between two
|
|
1631
|
+
* ideographs, is a place to break exactly where the same text with no edge
|
|
1632
|
+
* in it is one; a list of classes here would be a third opinion.
|
|
1633
|
+
*
|
|
1634
|
+
* Counted, not located: the words run together break into as many pieces
|
|
1635
|
+
* as the two apart where they break between them, and one fewer where the
|
|
1636
|
+
* last piece of the one runs on into the first of the other. The line
|
|
1637
|
+
* offsets are no answer, as they are not the text's on every backend: the
|
|
1638
|
+
* CoreText engine lays out a copy with a line break put in at each place,
|
|
1639
|
+
* and its offsets are that copy's.
|
|
1640
|
+
*/
|
|
1641
|
+
function breaksBetween(fonts, base, before, end, after, anywhere) {
|
|
1642
|
+
const last = charAt(before, end - 1);
|
|
1643
|
+
const next = charAt(after, 0);
|
|
1644
|
+
// nothing to hold the two together
|
|
1645
|
+
if (last === '' || next === '')
|
|
1646
|
+
return true;
|
|
1647
|
+
if (SPACE.test(last))
|
|
1648
|
+
return true;
|
|
1649
|
+
if (SPACE.test(next))
|
|
1650
|
+
return false;
|
|
1651
|
+
if (anywhere)
|
|
1652
|
+
return true;
|
|
1653
|
+
if (HOLDS.test(last) && HOLDS.test(next))
|
|
1654
|
+
return false;
|
|
1655
|
+
if (NO_BREAK_AFTER.test(last) || NO_BREAK_BEFORE.test(next))
|
|
1656
|
+
return false;
|
|
1657
|
+
const tail = sliceRuns(before, lastSpace(before, end) + 1, end);
|
|
1658
|
+
const head = sliceRuns(after, 0, firstSpace(after));
|
|
1659
|
+
const pieces = (runs) => fonts.layout(runs, base, {
|
|
1660
|
+
maxWidth: MIN_CONTENT_WIDTH,
|
|
1661
|
+
overflowWrap: 'normal',
|
|
1662
|
+
}).lines.length;
|
|
1663
|
+
return pieces([...tail, ...head]) === pieces(tail) + pieces(head);
|
|
1664
|
+
}
|
|
1665
|
+
/** The width a text is laid out at to break it at every place it may
|
|
1666
|
+
* break: none — at a pixel, CoreText breaks inside words (`block.ts`'s
|
|
1667
|
+
* `MIN_CONTENT_PROBE`). */
|
|
1668
|
+
const MIN_CONTENT_WIDTH = 0;
|
|
1669
|
+
/** White space a line breaks after. */
|
|
1670
|
+
const SPACE = /^[ \t\n]$/;
|
|
1671
|
+
/** Letters and digits of the Latin, Greek and Cyrillic alphabets, and the
|
|
1672
|
+
* marks on them: no line breaks between two of these (UAX #14, LB9, LB23,
|
|
1673
|
+
* LB25, LB28). */
|
|
1674
|
+
const HOLDS = /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}\p{M}]$/u;
|
|
1675
|
+
/** What no line breaks after, whatever follows: an opening bracket, a
|
|
1676
|
+
* straight quote and a no-break space (UAX #14, LB12, LB14, LB19). */
|
|
1677
|
+
const NO_BREAK_AFTER = /^[([{"'\u00a0]$/;
|
|
1678
|
+
/** What no line breaks before, whatever precedes: a closing bracket, a
|
|
1679
|
+
* stop, a comma, a colon, `!`, `?`, `/` and a straight quote (UAX #14,
|
|
1680
|
+
* LB13, LB19) — the punctuation after a link or a `<code>`, which needs
|
|
1681
|
+
* no asking either. */
|
|
1682
|
+
const NO_BREAK_BEFORE = /^[)\]},.;:!?/"']$/;
|
|
1683
|
+
/** The code unit at an offset into runs' joined text, or '' outside it. */
|
|
1684
|
+
function charAt(runs, at) {
|
|
1685
|
+
if (at < 0)
|
|
1686
|
+
return '';
|
|
1687
|
+
let from = 0;
|
|
1688
|
+
for (const run of runs) {
|
|
1689
|
+
const next = from + run.text.length;
|
|
1690
|
+
if (at < next)
|
|
1691
|
+
return run.text[at - from];
|
|
1692
|
+
from = next;
|
|
1693
|
+
}
|
|
1694
|
+
return '';
|
|
1695
|
+
}
|
|
1696
|
+
/** Where the last white space before a code-unit offset is, or -1. */
|
|
1697
|
+
function lastSpace(runs, end) {
|
|
1698
|
+
let found = -1;
|
|
1699
|
+
let from = 0;
|
|
1700
|
+
for (const run of runs) {
|
|
1701
|
+
if (from >= end)
|
|
1702
|
+
break;
|
|
1703
|
+
const text = run.text.slice(0, end - from);
|
|
1704
|
+
const at = Math.max(text.lastIndexOf(' '), text.lastIndexOf('\t'), text.lastIndexOf('\n'));
|
|
1705
|
+
if (at >= 0)
|
|
1706
|
+
found = from + at;
|
|
1707
|
+
from += run.text.length;
|
|
1708
|
+
}
|
|
1709
|
+
return found;
|
|
1710
|
+
}
|
|
1711
|
+
/** Where the first white space in runs' joined text is, or its length. */
|
|
1712
|
+
function firstSpace(runs) {
|
|
1713
|
+
let from = 0;
|
|
1714
|
+
for (const run of runs) {
|
|
1715
|
+
const at = run.text.search(/[ \t\n]/);
|
|
1716
|
+
if (at >= 0)
|
|
1717
|
+
return from + at;
|
|
1718
|
+
from += run.text.length;
|
|
1719
|
+
}
|
|
1720
|
+
return from;
|
|
1721
|
+
}
|
|
1722
|
+
function runsLength(runs) {
|
|
1723
|
+
let length = 0;
|
|
1724
|
+
for (const run of runs)
|
|
1725
|
+
length += run.text.length;
|
|
1726
|
+
return length;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The text inline boxes' edges from `from` on stand before, up to its first
|
|
1730
|
+
* white space: what the text before the edges runs on into. Null where an
|
|
1731
|
+
* atomic comes first, which has a break before it, or nothing does.
|
|
1732
|
+
*/
|
|
1733
|
+
function textAfterEdges(items, from) {
|
|
1734
|
+
const runs = [];
|
|
1735
|
+
for (let i = from; i < items.length; i += 1) {
|
|
1736
|
+
const item = items[i];
|
|
1737
|
+
if (item.kind === 'edge' || item.kind === 'float')
|
|
1738
|
+
continue;
|
|
1739
|
+
if (item.kind === 'atomic')
|
|
1740
|
+
break;
|
|
1741
|
+
const text = item.run.text;
|
|
1742
|
+
const stop = text.search(/[ \t\n]/);
|
|
1743
|
+
if (stop < 0) {
|
|
1744
|
+
runs.push(item.run);
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
runs.push({ ...item.run, text: text.slice(0, stop + 1) });
|
|
1748
|
+
break;
|
|
1749
|
+
}
|
|
1750
|
+
return runs.length ? runs : null;
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* The most `unbreakableAfter` could come to, with no layout: the edges on
|
|
1754
|
+
* the way, and the text up to its first white space at half again its size
|
|
1755
|
+
* a character, which no glyph a document sets words in is wider than, and
|
|
1756
|
+
* its letter spacing (`longestWord`'s estimate).
|
|
1757
|
+
*/
|
|
1758
|
+
function unbreakableBound(items, from) {
|
|
1759
|
+
let width = 0;
|
|
1760
|
+
for (let i = from; i < items.length; i += 1) {
|
|
1761
|
+
const item = items[i];
|
|
1762
|
+
if (item.kind === 'float')
|
|
1763
|
+
continue;
|
|
1764
|
+
if (item.kind === 'edge') {
|
|
1765
|
+
width += Math.max(0, item.width);
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
if (item.kind === 'atomic')
|
|
1769
|
+
break;
|
|
1770
|
+
const run = item.run;
|
|
1771
|
+
const stop = run.text.search(/[ \t\n]/);
|
|
1772
|
+
const per = (run.size ?? 16) * 1.5 + Math.max(0, run.letterSpacing ?? 0);
|
|
1773
|
+
width += (stop < 0 ? run.text.length : stop) * per;
|
|
1774
|
+
if (stop >= 0)
|
|
1775
|
+
break;
|
|
1776
|
+
}
|
|
1777
|
+
return width;
|
|
1778
|
+
}
|
|
1779
|
+
/** How wide the edges from `from` on are that close inline boxes, up to
|
|
1780
|
+
* one that opens a box or anything but an edge or a float. */
|
|
1781
|
+
function closingEdges(items, from) {
|
|
1782
|
+
let width = 0;
|
|
1783
|
+
for (let i = from; i < items.length; i += 1) {
|
|
1784
|
+
const item = items[i];
|
|
1785
|
+
if (item.kind === 'float')
|
|
1786
|
+
continue;
|
|
1787
|
+
if (item.kind !== 'edge' || item.side !== 'end')
|
|
1788
|
+
break;
|
|
1789
|
+
width += item.width;
|
|
1790
|
+
}
|
|
1791
|
+
return width;
|
|
1792
|
+
}
|
|
1793
|
+
/** Whether a code-unit offset into runs' joined text is between two
|
|
1794
|
+
* letters or digits: inside a word, where only `overflow-wrap` breaks. */
|
|
1795
|
+
function insideWord(runs, at) {
|
|
1796
|
+
return (WORD_CHAR.test(charAt(runs, at - 1)) && WORD_CHAR.test(charAt(runs, at)));
|
|
1797
|
+
}
|
|
1317
1798
|
/**
|
|
1318
1799
|
* How wide the content from `from` on is up to where its line may break:
|
|
1319
1800
|
* its text up to a space it may break at — one `holdNoWrap` left a space —
|
|
@@ -1349,7 +1830,9 @@ function unbreakableAfter(items, from, style, fonts, base) {
|
|
|
1349
1830
|
runs.push({ ...item.run, text: text.slice(0, stop) });
|
|
1350
1831
|
break;
|
|
1351
1832
|
}
|
|
1352
|
-
return runs.length
|
|
1833
|
+
return runs.length
|
|
1834
|
+
? width + fonts.layout(runs, base, { fit: PARAGRAPH_FIT }).width
|
|
1835
|
+
: width;
|
|
1353
1836
|
}
|
|
1354
1837
|
/**
|
|
1355
1838
|
* Close a line: its height and baseline from what is on it, then where it
|
|
@@ -1357,11 +1840,16 @@ function unbreakableAfter(items, from, style, fonts, base) {
|
|
|
1357
1840
|
* it by `shift` of the room it did not use (0 flush left, 1 flush right, ½
|
|
1358
1841
|
* centred), moved onto every text, atomic and edge on it at once.
|
|
1359
1842
|
*/
|
|
1360
|
-
function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null
|
|
1843
|
+
function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify = null,
|
|
1844
|
+
/** The paragraph's bidi levels, where it has them (`ParagraphBidi`). */
|
|
1845
|
+
levels = null) {
|
|
1361
1846
|
if (!open.texts.length && !open.atomics.length && !open.edges.length) {
|
|
1362
1847
|
return null;
|
|
1363
1848
|
}
|
|
1364
|
-
const reordered =
|
|
1849
|
+
const reordered = levels
|
|
1850
|
+
? levelPieces(open, levels) ||
|
|
1851
|
+
open.order.some((p) => p.kind === 'edge' && needsOrdering(p, rtl))
|
|
1852
|
+
: open.order.some((p) => needsOrdering(p, rtl));
|
|
1365
1853
|
if (reordered)
|
|
1366
1854
|
reorderLine(open, rtl);
|
|
1367
1855
|
// Every line box starts with the block's strut, its face at its line
|
|
@@ -1404,7 +1892,9 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
|
|
|
1404
1892
|
continue;
|
|
1405
1893
|
}
|
|
1406
1894
|
const natural = open.texts[i].layout.lines[open.texts[i].layoutLine];
|
|
1407
|
-
|
|
1895
|
+
// with its leading shared as the strut's is: it is drawn on the line's
|
|
1896
|
+
// baseline, wherever its engine put its own
|
|
1897
|
+
const own = lineAscent(natural);
|
|
1408
1898
|
ascent = Math.max(ascent, own);
|
|
1409
1899
|
descent = Math.max(descent, natural.height - own);
|
|
1410
1900
|
}
|
|
@@ -1570,10 +2060,8 @@ function finishLine(open, y, bandFor, shift, rtl, strut, lifts = null, justify =
|
|
|
1570
2060
|
* UAX #9's L2 over pieces rather than characters: a fragment is in order
|
|
1571
2061
|
* inside itself already — the engine laid it out bidirectionally — so what
|
|
1572
2062
|
* is left to order is the fragments and the atomics between them, each at
|
|
1573
|
-
* a level
|
|
1574
|
-
*
|
|
1575
|
-
* fragment of spaces and digits) its neighbours' where they agree and the
|
|
1576
|
-
* paragraph's where they do not (N1, N2).
|
|
2063
|
+
* a level: the paragraph's own, where it has them (`levelPieces`), and
|
|
2064
|
+
* otherwise the one it reads at (`readingLevels`).
|
|
1577
2065
|
*
|
|
1578
2066
|
* Then each element's edges, innermost first, around what of it is on the
|
|
1579
2067
|
* line: CSS 2.1 8.6 puts an ltr element's left margin, border and padding
|
|
@@ -1592,28 +2080,16 @@ function reorderLine(open, rtl) {
|
|
|
1592
2080
|
for (let i = 0; i < n; i += 1)
|
|
1593
2081
|
if (order[i].kind !== 'edge')
|
|
1594
2082
|
content.push(i);
|
|
1595
|
-
|
|
2083
|
+
// UAX #9's levels where the paragraph's are known (`levelPieces`), each
|
|
2084
|
+
// piece at one of them; its reading otherwise
|
|
2085
|
+
const given = content.map((i) => {
|
|
1596
2086
|
const p = order[i];
|
|
1597
|
-
|
|
1598
|
-
return null;
|
|
1599
|
-
return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
|
|
2087
|
+
return p.kind === 'edge' ? undefined : p.level;
|
|
1600
2088
|
});
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
}
|
|
1606
|
-
return rtl;
|
|
1607
|
-
};
|
|
1608
|
-
const isRtl = strong.map((r, k) => {
|
|
1609
|
-
if (r !== null)
|
|
1610
|
-
return r;
|
|
1611
|
-
const before = strongAt(k, -1);
|
|
1612
|
-
return before === strongAt(k, 1) ? before : rtl;
|
|
1613
|
-
});
|
|
1614
|
-
// R is level 1 in either paragraph; L is 0 in a left-to-right one and 2
|
|
1615
|
-
// in a right-to-left one
|
|
1616
|
-
const levels = isRtl.map((r) => (r ? 1 : rtl ? 2 : 0));
|
|
2089
|
+
const levels = given.every((level) => level !== undefined)
|
|
2090
|
+
? given
|
|
2091
|
+
: readingLevels(order, content, rtl);
|
|
2092
|
+
const isRtl = levels.map((level) => (level & 1) === 1);
|
|
1617
2093
|
const visual = content.map((_, k) => k);
|
|
1618
2094
|
for (let level = Math.max(0, ...levels); level >= 1; level -= 1) {
|
|
1619
2095
|
for (let a = 0; a < visual.length;) {
|
|
@@ -1688,6 +2164,9 @@ function reorderLine(open, rtl) {
|
|
|
1688
2164
|
if (isRtl[k] && natural)
|
|
1689
2165
|
dx += Math.max(0, room - natural.width);
|
|
1690
2166
|
piece.item.drawX += dx;
|
|
2167
|
+
// and the spaces it hung are on the side of it its room is left on
|
|
2168
|
+
if (piece.item.hung)
|
|
2169
|
+
placeHung(piece.item, isRtl[k]);
|
|
1691
2170
|
}
|
|
1692
2171
|
else {
|
|
1693
2172
|
piece.item.x += dx;
|
|
@@ -1695,6 +2174,399 @@ function reorderLine(open, rtl) {
|
|
|
1695
2174
|
x += room;
|
|
1696
2175
|
}
|
|
1697
2176
|
}
|
|
2177
|
+
/**
|
|
2178
|
+
* The levels of a line's pieces from which way each reads, where the
|
|
2179
|
+
* paragraph's are not known: a fragment the direction its letters read in,
|
|
2180
|
+
* or the paragraph's where they read both ways, and a neutral — an atomic, a
|
|
2181
|
+
* fragment of spaces and digits — its neighbours' where they agree and the
|
|
2182
|
+
* paragraph's where they do not (N1, N2). R is level 1 in either paragraph;
|
|
2183
|
+
* L is 0 in a left-to-right one and 2 in a right-to-left one.
|
|
2184
|
+
*/
|
|
2185
|
+
function readingLevels(order, content, rtl) {
|
|
2186
|
+
const strong = content.map((i) => {
|
|
2187
|
+
const p = order[i];
|
|
2188
|
+
if (p.kind !== 'text' || p.reads === null)
|
|
2189
|
+
return null;
|
|
2190
|
+
return p.reads === 'mixed' ? rtl : p.reads === 'rtl';
|
|
2191
|
+
});
|
|
2192
|
+
const strongAt = (from, step) => {
|
|
2193
|
+
for (let k = from + step; k >= 0 && k < content.length; k += step) {
|
|
2194
|
+
if (strong[k] !== null)
|
|
2195
|
+
return strong[k];
|
|
2196
|
+
}
|
|
2197
|
+
return rtl;
|
|
2198
|
+
};
|
|
2199
|
+
return strong.map((r, k) => {
|
|
2200
|
+
const reads = r ?? (strongAt(k, -1) === strongAt(k, 1) ? strongAt(k, -1) : rtl);
|
|
2201
|
+
return reads ? 1 : rtl ? 2 : 0;
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
let BIDI = null;
|
|
2205
|
+
/** bidi-js, made the first time a paragraph needs it. Its declarations
|
|
2206
|
+
* give a CommonJS module a default export, which NodeNext reads as the
|
|
2207
|
+
* module object; Node and a bundler both hand over its factory. */
|
|
2208
|
+
function bidiJs() {
|
|
2209
|
+
if (!BIDI) {
|
|
2210
|
+
const factory = bidiModule;
|
|
2211
|
+
BIDI = (typeof factory === 'function' ? factory : factory.default)();
|
|
2212
|
+
}
|
|
2213
|
+
return BIDI;
|
|
2214
|
+
}
|
|
2215
|
+
/** A paragraph's levels, or null where nothing in it can be reordered:
|
|
2216
|
+
* it reads left to right, and holds no right-to-left letter and no bidi
|
|
2217
|
+
* control. Kept on its block, and resolved again only where its text is
|
|
2218
|
+
* not what it was: a pass at another width has the same. */
|
|
2219
|
+
function paragraphBidi(block, items, rtl) {
|
|
2220
|
+
let reorders = rtl;
|
|
2221
|
+
for (let i = 0; !reorders && i < items.length; i += 1) {
|
|
2222
|
+
const item = items[i];
|
|
2223
|
+
if (item.kind === 'text' && REORDERS.test(item.run.text))
|
|
2224
|
+
reorders = true;
|
|
2225
|
+
}
|
|
2226
|
+
if (!reorders)
|
|
2227
|
+
return null;
|
|
2228
|
+
const starts = [];
|
|
2229
|
+
let text = '';
|
|
2230
|
+
for (const item of items) {
|
|
2231
|
+
starts.push(text.length);
|
|
2232
|
+
if (item.kind === 'text')
|
|
2233
|
+
text += item.run.text;
|
|
2234
|
+
else if (item.kind === 'atomic')
|
|
2235
|
+
text += '\ufffc';
|
|
2236
|
+
}
|
|
2237
|
+
const base = rtl ? 1 : 0;
|
|
2238
|
+
const kept = PARAGRAPH_BIDI.get(block);
|
|
2239
|
+
if (kept && kept.text === text && kept.base === base) {
|
|
2240
|
+
return { ...kept, starts };
|
|
2241
|
+
}
|
|
2242
|
+
const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
|
|
2243
|
+
const explicit = EXPLICIT.test(text);
|
|
2244
|
+
const bidi = { text, levels, explicit, base, starts };
|
|
2245
|
+
PARAGRAPH_BIDI.set(block, bidi);
|
|
2246
|
+
return bidi;
|
|
2247
|
+
}
|
|
2248
|
+
const PARAGRAPH_BIDI = new WeakMap();
|
|
2249
|
+
/** The explicit embeddings, overrides and isolates. */
|
|
2250
|
+
const EXPLICIT = /[\u202a-\u202e\u2066-\u2069]/;
|
|
2251
|
+
/** The explicit embeddings, overrides and isolates a text ends on. */
|
|
2252
|
+
const ENDING_CONTROLS = /[\u202a-\u202e\u2066-\u2069]+$/;
|
|
2253
|
+
/** Whether a character is one X9 removes, or an isolate: a bidi control,
|
|
2254
|
+
* or a boundary neutral such as a zero-width space or a soft hyphen. */
|
|
2255
|
+
function isControl(c) {
|
|
2256
|
+
return ((c >= 0x202a && c <= 0x202e) ||
|
|
2257
|
+
(c >= 0x2066 && c <= 0x2069) ||
|
|
2258
|
+
(c >= 0x200b && c <= 0x200d) ||
|
|
2259
|
+
(c >= 0x2060 && c <= 0x2064) ||
|
|
2260
|
+
c === 0xad ||
|
|
2261
|
+
c === 0xfeff ||
|
|
2262
|
+
c === 0x180e);
|
|
2263
|
+
}
|
|
2264
|
+
/** The paired brackets UAX #9 resolves together (N0), which may pair
|
|
2265
|
+
* across a piece's end. */
|
|
2266
|
+
const BRACKETS = /[()[\]{}\u0f3a-\u0f3d\u169b\u169c\u2045\u2046\u207d\u207e\u208d\u208e\u2308-\u230b\u2329\u232a\u2768-\u2775\u27c5\u27c6\u27e6-\u27ef\u2983-\u2998\u29d8-\u29db\u29fc\u29fd\u2e22-\u2e29\u3008-\u3011\u3014-\u301b\ufe59-\ufe5e\uff08\uff09\uff3b\uff3d\uff5b\uff5d\uff5f\uff60\uff62\uff63]/;
|
|
2267
|
+
/** The strong types, which nothing around them resolves again. */
|
|
2268
|
+
const STRONG = new Set(['L', 'R', 'AL']);
|
|
2269
|
+
/** What UAX #9 L1 puts back at the paragraph's level where a line ends on
|
|
2270
|
+
* it: white space, the isolates, and what X9 removed. */
|
|
2271
|
+
const TRAILING = new Set([
|
|
2272
|
+
'WS',
|
|
2273
|
+
'S',
|
|
2274
|
+
'B',
|
|
2275
|
+
'LRI',
|
|
2276
|
+
'RLI',
|
|
2277
|
+
'FSI',
|
|
2278
|
+
'PDI',
|
|
2279
|
+
'BN',
|
|
2280
|
+
'LRE',
|
|
2281
|
+
'RLE',
|
|
2282
|
+
'LRO',
|
|
2283
|
+
'RLO',
|
|
2284
|
+
'PDF',
|
|
2285
|
+
]);
|
|
2286
|
+
/** A segment's text, and its levels as the engine resolves them, laid out
|
|
2287
|
+
* alone in the paragraph's direction. */
|
|
2288
|
+
const ALONE = new WeakMap();
|
|
2289
|
+
function aloneLevels(runs, rtl) {
|
|
2290
|
+
let alone = ALONE.get(runs);
|
|
2291
|
+
if (!alone) {
|
|
2292
|
+
const text = runs.map((run) => run.text).join('');
|
|
2293
|
+
const { levels } = bidiJs().getEmbeddingLevels(text, rtl ? 'rtl' : 'ltr');
|
|
2294
|
+
alone = { text, levels };
|
|
2295
|
+
ALONE.set(runs, alone);
|
|
2296
|
+
}
|
|
2297
|
+
return alone;
|
|
2298
|
+
}
|
|
2299
|
+
/** Whether the text from a segment on, laid out alone, is ordered as the
|
|
2300
|
+
* paragraph orders it: every letter at the level it has there. */
|
|
2301
|
+
function orderedAlone(bidi, segment, para, rtl) {
|
|
2302
|
+
const { text, levels } = aloneLevels(segment.runs, rtl);
|
|
2303
|
+
for (let at = 0; at < levels.length; at += 1) {
|
|
2304
|
+
if (segment.spans.isGap(at))
|
|
2305
|
+
continue;
|
|
2306
|
+
if (levels[at] === bidi.levels[para + at])
|
|
2307
|
+
continue;
|
|
2308
|
+
if (!isControl(text.charCodeAt(at)))
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
return true;
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Give each piece on a line its level, and answer whether the line is to be
|
|
2315
|
+
* put in visual order at all. A piece's letters have the paragraph's levels,
|
|
2316
|
+
* with the white space the line ends on back at the paragraph's (L1). A
|
|
2317
|
+
* fragment was laid out alone and ordered inside itself by the engine: it
|
|
2318
|
+
* is kept whole where the engine's levels for it are the paragraph's and it
|
|
2319
|
+
* begins and ends at its lowest one, so that nothing of another piece comes
|
|
2320
|
+
* between its letters (L2), and it is the line's at that level. It is split
|
|
2321
|
+
* otherwise, a layout to each run of its letters at one level, held in that
|
|
2322
|
+
* level's direction by an override; the controls in it, which take no
|
|
2323
|
+
* room, are left out. An atomic is at its replacement character's level.
|
|
2324
|
+
*/
|
|
2325
|
+
function levelPieces(open, line) {
|
|
2326
|
+
const { bidi } = line;
|
|
2327
|
+
let start = Infinity;
|
|
2328
|
+
let end = -Infinity;
|
|
2329
|
+
for (const p of open.order) {
|
|
2330
|
+
if (p.kind === 'text' && p.from) {
|
|
2331
|
+
const natural = p.item.layout.lines[p.item.layoutLine];
|
|
2332
|
+
if (!natural)
|
|
2333
|
+
continue;
|
|
2334
|
+
start = Math.min(start, p.from.para + natural.start);
|
|
2335
|
+
end = Math.max(end, p.from.para + natural.end);
|
|
2336
|
+
}
|
|
2337
|
+
else if (p.kind === 'atomic' && p.para !== undefined) {
|
|
2338
|
+
start = Math.min(start, p.para);
|
|
2339
|
+
end = Math.max(end, p.para + 1);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
// an element's edges alone are ordered as the paragraph reads
|
|
2343
|
+
if (!(end > start))
|
|
2344
|
+
return bidi.base > 0;
|
|
2345
|
+
const levels = bidi.levels.slice(start, end);
|
|
2346
|
+
const js = bidiJs();
|
|
2347
|
+
for (let i = end - 1; i >= start; i -= 1) {
|
|
2348
|
+
if (!TRAILING.has(js.getBidiCharTypeName(bidi.text[i])))
|
|
2349
|
+
break;
|
|
2350
|
+
levels[i - start] = bidi.base;
|
|
2351
|
+
}
|
|
2352
|
+
const levelAt = (at) => levels[at - start];
|
|
2353
|
+
let off = false;
|
|
2354
|
+
const order = [];
|
|
2355
|
+
const texts = [];
|
|
2356
|
+
for (const p of open.order) {
|
|
2357
|
+
if (p.kind === 'edge') {
|
|
2358
|
+
order.push(p);
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
if (p.kind === 'atomic') {
|
|
2362
|
+
p.level = p.para === undefined ? bidi.base : levelAt(p.para);
|
|
2363
|
+
off ||= p.level > 0;
|
|
2364
|
+
order.push(p);
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
for (const piece of levelled(p, levelAt, line)) {
|
|
2368
|
+
off ||= piece.level > 0;
|
|
2369
|
+
order.push(piece);
|
|
2370
|
+
texts.push(piece.item);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
open.order = order;
|
|
2374
|
+
open.texts = texts;
|
|
2375
|
+
return off;
|
|
2376
|
+
}
|
|
2377
|
+
/** A fragment of a line as the pieces it is ordered in (`levelPieces`). */
|
|
2378
|
+
function levelled(p, levelAt, line) {
|
|
2379
|
+
const text = p.item;
|
|
2380
|
+
const natural = text.layout.lines[text.layoutLine];
|
|
2381
|
+
const base = line.bidi.base;
|
|
2382
|
+
if (!p.from || !natural)
|
|
2383
|
+
return [{ ...p, level: base }];
|
|
2384
|
+
const { para, runs, spans } = p.from;
|
|
2385
|
+
const paragraph = line.bidi.text;
|
|
2386
|
+
// A bidi control takes no room and has no level of its own, whether
|
|
2387
|
+
// `unicode-bidi` stands for it or the document holds it: X9 removes it,
|
|
2388
|
+
// and bidi-js gives it the level of what is before it, which may be
|
|
2389
|
+
// another piece's. It is no end of a piece, nor of a run of one level.
|
|
2390
|
+
const quiet = (at) => spans.isGap(at) || isControl(paragraph.charCodeAt(para + at));
|
|
2391
|
+
let lowest = Infinity;
|
|
2392
|
+
let first = -1;
|
|
2393
|
+
let last = -1;
|
|
2394
|
+
for (let at = natural.start; at < natural.end; at += 1) {
|
|
2395
|
+
if (quiet(at))
|
|
2396
|
+
continue;
|
|
2397
|
+
if (first < 0)
|
|
2398
|
+
first = at;
|
|
2399
|
+
last = at;
|
|
2400
|
+
lowest = Math.min(lowest, levelAt(para + at));
|
|
2401
|
+
}
|
|
2402
|
+
if (first < 0)
|
|
2403
|
+
return [{ ...p, level: base }];
|
|
2404
|
+
if (levelAt(para + first) === lowest &&
|
|
2405
|
+
levelAt(para + last) === lowest &&
|
|
2406
|
+
(plainlyLevelled(line.bidi, p.from, first, last, levelAt) ||
|
|
2407
|
+
laidAsLevelled(p.from, natural, levelAt, base))) {
|
|
2408
|
+
return [{ ...p, level: lowest }];
|
|
2409
|
+
}
|
|
2410
|
+
const starts = runStarts(runs);
|
|
2411
|
+
const out = [];
|
|
2412
|
+
let at = p.at;
|
|
2413
|
+
let lastRuns = [];
|
|
2414
|
+
/** Where each piece is in the fragment's segment. */
|
|
2415
|
+
const ranges = [];
|
|
2416
|
+
const piece = (from, to, level) => {
|
|
2417
|
+
// its letters, each run's with where it is in the document and whose
|
|
2418
|
+
// text it is
|
|
2419
|
+
const letters = [];
|
|
2420
|
+
let points = 0;
|
|
2421
|
+
for (let k = runIndexAt(starts, from); k < runs.length; k += 1) {
|
|
2422
|
+
const runStart = starts[k];
|
|
2423
|
+
if (runStart >= to)
|
|
2424
|
+
break;
|
|
2425
|
+
const a = Math.max(from, runStart);
|
|
2426
|
+
const b = Math.min(to, runStart + runs[k].text.length);
|
|
2427
|
+
if (b <= a || spans.isGap(a))
|
|
2428
|
+
continue;
|
|
2429
|
+
const slice = runs[k].text.slice(a - runStart, b - runStart);
|
|
2430
|
+
letters.push({
|
|
2431
|
+
run: slice === runs[k].text ? runs[k] : { ...runs[k], text: slice },
|
|
2432
|
+
doc: spans.documentAt(a),
|
|
2433
|
+
box: spans.boxAt(a),
|
|
2434
|
+
});
|
|
2435
|
+
points += codeUnitOffsets(slice).length - 1;
|
|
2436
|
+
}
|
|
2437
|
+
if (!letters.length)
|
|
2438
|
+
return;
|
|
2439
|
+
const head = letters[0];
|
|
2440
|
+
const tail = letters[letters.length - 1];
|
|
2441
|
+
const docStart = head.doc;
|
|
2442
|
+
const docEnd = tail.doc + tail.run.text.length;
|
|
2443
|
+
const face = (run, control) => ({
|
|
2444
|
+
text: control,
|
|
2445
|
+
family: run.family,
|
|
2446
|
+
size: run.size,
|
|
2447
|
+
weight: run.weight,
|
|
2448
|
+
style: run.style,
|
|
2449
|
+
});
|
|
2450
|
+
const rtl = (level & 1) === 1;
|
|
2451
|
+
const laidRuns = [face(head.run, rtl ? '\u202e' : '\u202d')];
|
|
2452
|
+
const laidSpans = new SpanMap();
|
|
2453
|
+
laidSpans.add(docStart, 1, head.box, true);
|
|
2454
|
+
for (const { run, doc, box } of letters) {
|
|
2455
|
+
laidSpans.add(doc, run.text.length, box);
|
|
2456
|
+
laidRuns.push(run);
|
|
2457
|
+
}
|
|
2458
|
+
laidRuns.push(face(tail.run, '\u202c'));
|
|
2459
|
+
laidSpans.add(docEnd, 1, tail.box, true);
|
|
2460
|
+
const layout = line.fonts.layout(laidRuns, line.base, {
|
|
2461
|
+
lineHeight: line.lineHeight,
|
|
2462
|
+
align: 'left',
|
|
2463
|
+
direction: rtl ? 'rtl' : 'ltr',
|
|
2464
|
+
fit: PARAGRAPH_FIT,
|
|
2465
|
+
});
|
|
2466
|
+
LAYOUT_RUNS.set(layout, laidRuns);
|
|
2467
|
+
lastRuns = laidRuns;
|
|
2468
|
+
const laid = layout.lines[0];
|
|
2469
|
+
const item = {
|
|
2470
|
+
layout,
|
|
2471
|
+
layoutLine: 0,
|
|
2472
|
+
drawX: text.drawX + (at - p.at),
|
|
2473
|
+
drawY: 0,
|
|
2474
|
+
textStart: docStart,
|
|
2475
|
+
textEnd: docEnd,
|
|
2476
|
+
layoutStart: laid ? laid.start : 0,
|
|
2477
|
+
spans: laidSpans,
|
|
2478
|
+
};
|
|
2479
|
+
laidSpans.giveGaps(item, 0, laidSpans.laidOut);
|
|
2480
|
+
out.push({ kind: 'text', at, item, reads: null, level });
|
|
2481
|
+
ranges.push([from, to]);
|
|
2482
|
+
// the room it takes, its white space at its end with it, which the
|
|
2483
|
+
// engine hangs past the layout's width: from caret to caret
|
|
2484
|
+
const extent = Math.abs(layout.caretPosition(1 + points).x - layout.caretPosition(1).x);
|
|
2485
|
+
at += Math.max(laid?.width ?? 0, extent);
|
|
2486
|
+
};
|
|
2487
|
+
let from = first;
|
|
2488
|
+
let level = levelAt(para + first);
|
|
2489
|
+
let prev = first;
|
|
2490
|
+
for (let o = first + 1; o <= last; o += 1) {
|
|
2491
|
+
if (quiet(o))
|
|
2492
|
+
continue;
|
|
2493
|
+
const l = levelAt(para + o);
|
|
2494
|
+
if (l !== level) {
|
|
2495
|
+
piece(from, prev + 1, level);
|
|
2496
|
+
from = o;
|
|
2497
|
+
level = l;
|
|
2498
|
+
}
|
|
2499
|
+
prev = o;
|
|
2500
|
+
}
|
|
2501
|
+
piece(from, last + 1, level);
|
|
2502
|
+
if (!out.length)
|
|
2503
|
+
return [{ ...p, level: base }];
|
|
2504
|
+
// a word across two of the pieces runs on from one to the next, with no
|
|
2505
|
+
// edge between, as the fragment ran on from the text before it (`JOINS`)
|
|
2506
|
+
const joins = JOINS.get(text);
|
|
2507
|
+
if (joins !== undefined)
|
|
2508
|
+
JOINS.set(out[0].item, joins);
|
|
2509
|
+
for (let k = 1; k < out.length; k += 1) {
|
|
2510
|
+
const before = paragraph[para + ranges[k - 1][1] - 1];
|
|
2511
|
+
const after = paragraph[para + ranges[k][0]];
|
|
2512
|
+
if (!SPACE.test(before) && !SPACE.test(after))
|
|
2513
|
+
JOINS.set(out[k].item, 0);
|
|
2514
|
+
}
|
|
2515
|
+
// the spaces `pre-wrap` keeps that the fragment ended on are its last
|
|
2516
|
+
// piece's, which the engine hangs as it hung them (`hangPreserved`)
|
|
2517
|
+
const tail = out[out.length - 1];
|
|
2518
|
+
if (text.hung) {
|
|
2519
|
+
const rtl = (tail.level & 1) === 1;
|
|
2520
|
+
hangPreserved(tail.item, lastRuns, runStarts(lastRuns), line.fonts, rtl);
|
|
2521
|
+
}
|
|
2522
|
+
return out;
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Whether the engine, laying a fragment out alone, must have given its
|
|
2526
|
+
* letters the paragraph's levels, without resolving them to see: in a
|
|
2527
|
+
* paragraph of no explicit embedding, a stretch that begins and ends with a
|
|
2528
|
+
* strong letter and pairs no bracket is resolved inside itself, whatever is
|
|
2529
|
+
* around it (W1 to W7, N1 and N2 look no further than the strong letters
|
|
2530
|
+
* either side). The white space it ends on is the paragraph's level to the
|
|
2531
|
+
* engine, whose line ends there (L1), and has to be to the line.
|
|
2532
|
+
*/
|
|
2533
|
+
function plainlyLevelled(bidi, from, first, last, levelAt) {
|
|
2534
|
+
if (bidi.explicit)
|
|
2535
|
+
return false;
|
|
2536
|
+
const text = bidi.text;
|
|
2537
|
+
const js = bidiJs();
|
|
2538
|
+
let end = last;
|
|
2539
|
+
while (end > first &&
|
|
2540
|
+
TRAILING.has(js.getBidiCharTypeName(text[from.para + end]))) {
|
|
2541
|
+
if (levelAt(from.para + end) !== bidi.base)
|
|
2542
|
+
return false;
|
|
2543
|
+
end -= 1;
|
|
2544
|
+
}
|
|
2545
|
+
return (STRONG.has(js.getBidiCharTypeName(text[from.para + first])) &&
|
|
2546
|
+
STRONG.has(js.getBidiCharTypeName(text[from.para + end])) &&
|
|
2547
|
+
!BRACKETS.test(text.slice(from.para + first, from.para + end + 1)));
|
|
2548
|
+
}
|
|
2549
|
+
/** Whether the engine, laying a fragment out alone, gave its letters the
|
|
2550
|
+
* levels the paragraph gives them: with its line's end white space back
|
|
2551
|
+
* at the paragraph's level (L1), as the engine's own line ends there. */
|
|
2552
|
+
function laidAsLevelled(from, natural, levelAt, base) {
|
|
2553
|
+
const { text, levels } = aloneLevels(from.runs, base === 1);
|
|
2554
|
+
const js = bidiJs();
|
|
2555
|
+
const quiet = (at) => from.spans.isGap(at) || isControl(text.charCodeAt(at));
|
|
2556
|
+
let hung = natural.end;
|
|
2557
|
+
while (hung > natural.start &&
|
|
2558
|
+
TRAILING.has(js.getBidiCharTypeName(text[hung - 1]))) {
|
|
2559
|
+
hung -= 1;
|
|
2560
|
+
}
|
|
2561
|
+
for (let at = natural.start; at < natural.end; at += 1) {
|
|
2562
|
+
if (quiet(at))
|
|
2563
|
+
continue;
|
|
2564
|
+
const alone = at >= hung ? base : levels[at];
|
|
2565
|
+
if (alone !== levelAt(from.para + at))
|
|
2566
|
+
return false;
|
|
2567
|
+
}
|
|
2568
|
+
return true;
|
|
2569
|
+
}
|
|
1698
2570
|
/** Whether a piece can be out of place in logical order: anything on a
|
|
1699
2571
|
* right-to-left line, a fragment that reads right to left, or the edge of
|
|
1700
2572
|
* an element whose direction is not the line's. A line of left-to-right
|
|
@@ -1812,7 +2684,14 @@ function atomicRaise(fonts, box, parent) {
|
|
|
1812
2684
|
function atomicBaseline(box) {
|
|
1813
2685
|
const bottom = box.height + box.marginTop + box.marginBottom;
|
|
1814
2686
|
if (box.kind === 'replaced' ||
|
|
1815
|
-
|
|
2687
|
+
// a box that clips sits on its bottom margin edge, for legacy reasons
|
|
2688
|
+
// that stop at block containers (CSS Box Alignment 3, 9.2) — and an
|
|
2689
|
+
// inline table, as browsers keep it: an inline flex box that clips
|
|
2690
|
+
// sits on its first item's, as it does unclipped. A MediaWiki button
|
|
2691
|
+
// is an `overflow: hidden` inline flex box of one icon, and stood its
|
|
2692
|
+
// whole height on the baseline, the line under it the strut's descent
|
|
2693
|
+
// taller
|
|
2694
|
+
(scrolls(box.style) && box.kind !== 'flex') ||
|
|
1816
2695
|
// layout containment keeps its baseline in (CSS Containment 2, 3.3)
|
|
1817
2696
|
box.style.contain & CONTAIN_LAYOUT) {
|
|
1818
2697
|
return bottom;
|
|
@@ -1945,7 +2824,11 @@ function childBaseline(child, inside) {
|
|
|
1945
2824
|
if (child.outOfFlow || child.isFloat || child.kind === 'replaced') {
|
|
1946
2825
|
return null;
|
|
1947
2826
|
}
|
|
1948
|
-
|
|
2827
|
+
// the legacy rule is a block container's last baseline alone (CSS Box
|
|
2828
|
+
// Alignment 3, 9.2): its first is its first line's, clipped or not
|
|
2829
|
+
if (inside === lastBaselineIn &&
|
|
2830
|
+
child.kind !== 'flex' &&
|
|
2831
|
+
scrolls(child.style)) {
|
|
1949
2832
|
return child.y + child.height + child.marginBottom;
|
|
1950
2833
|
}
|
|
1951
2834
|
return inside(child);
|
|
@@ -2308,7 +3191,12 @@ function wordSpaced(out, box, run, spacing) {
|
|
|
2308
3191
|
run: {
|
|
2309
3192
|
...run,
|
|
2310
3193
|
text: text.slice(from, to),
|
|
2311
|
-
...(extra
|
|
3194
|
+
...(extra
|
|
3195
|
+
? {
|
|
3196
|
+
letterSpacing: (run.letterSpacing ?? 0) + extra,
|
|
3197
|
+
kernAcross: true,
|
|
3198
|
+
}
|
|
3199
|
+
: null),
|
|
2312
3200
|
},
|
|
2313
3201
|
box,
|
|
2314
3202
|
length: to - from,
|
|
@@ -2354,6 +3242,11 @@ function inlineEdges(box, width, fonts) {
|
|
|
2354
3242
|
}
|
|
2355
3243
|
}
|
|
2356
3244
|
box.decoration = inlineDecoration(fonts, box, s);
|
|
3245
|
+
if (!box.decoration && fonts && (box.padTop > 0 || box.padBottom > 0)) {
|
|
3246
|
+
PADDED_FACES.set(box, faceExtent(fonts, s));
|
|
3247
|
+
}
|
|
3248
|
+
else
|
|
3249
|
+
PADDED_FACES.delete(box);
|
|
2357
3250
|
const left = box.marginLeft + box.borderLeft + box.padLeft;
|
|
2358
3251
|
const right = box.padRight + box.borderRight + box.marginRight;
|
|
2359
3252
|
return s.direction === 'rtl' ? [right, left] : [left, right];
|
|
@@ -2365,7 +3258,12 @@ function inlineEdges(box, width, fonts) {
|
|
|
2365
3258
|
* a pointer move that gives a link a background (`HtmlViewNode`).
|
|
2366
3259
|
*/
|
|
2367
3260
|
export function inlineDecoration(fonts, box, s) {
|
|
3261
|
+
// a background is a colour, a gradient or an image: a span a gradient
|
|
3262
|
+
// underlines, a highlighter's, was painted only where it had a border
|
|
2368
3263
|
const decorated = !isTransparent(s.backgroundColor) ||
|
|
3264
|
+
s.backgroundGradient !== null ||
|
|
3265
|
+
s.backgroundImage !== null ||
|
|
3266
|
+
s.backgroundImages !== null ||
|
|
2369
3267
|
box.borderTop + box.borderRight + box.borderBottom + box.borderLeft > 0 ||
|
|
2370
3268
|
s.outlineStyle !== 'none';
|
|
2371
3269
|
return !decorated ? null : fonts ? faceExtent(fonts, s) : NO_EXTENT;
|
|
@@ -2434,10 +3332,27 @@ function firstLineSetting(block, items, how, fonts, strut, justify) {
|
|
|
2434
3332
|
const of = (box) => box === block || !box.parent
|
|
2435
3333
|
? style
|
|
2436
3334
|
: styleOnFirstLine(box, of(box.parent), how.styler);
|
|
3335
|
+
// and in its `text-transform` where the line has one of its own, which
|
|
3336
|
+
// the boxes' text was not made with: a word carried from one run into
|
|
3337
|
+
// the next, as the box builder carries it (`letteredAfter`)
|
|
3338
|
+
let lettered = false;
|
|
2437
3339
|
const restyled = items.map((item) => {
|
|
3340
|
+
if (item.kind === 'atomic')
|
|
3341
|
+
lettered = false;
|
|
2438
3342
|
if (item.kind !== 'text')
|
|
2439
3343
|
return item;
|
|
2440
|
-
const
|
|
3344
|
+
const own = item.box.style;
|
|
3345
|
+
const on = of(item.box);
|
|
3346
|
+
let run = restyledRun(item.run, own, on);
|
|
3347
|
+
const transform = on.textTransform;
|
|
3348
|
+
if (!item.control &&
|
|
3349
|
+
transform !== own.textTransform &&
|
|
3350
|
+
transform !== 'none') {
|
|
3351
|
+
const text = transformInPlace(run.text, transform, lettered);
|
|
3352
|
+
if (text !== run.text)
|
|
3353
|
+
run = { ...run, text };
|
|
3354
|
+
}
|
|
3355
|
+
lettered = letteredAfter(run.text, lettered);
|
|
2441
3356
|
return run === item.run ? item : { ...item, run };
|
|
2442
3357
|
});
|
|
2443
3358
|
const lineHeight = lineHeightMultiplier(fonts, style);
|
|
@@ -2761,6 +3676,180 @@ function heldText(box) {
|
|
|
2761
3676
|
return held;
|
|
2762
3677
|
}
|
|
2763
3678
|
const HELD = new WeakMap();
|
|
3679
|
+
/**
|
|
3680
|
+
* Whether a line one layout made is shorter about its baseline than the
|
|
3681
|
+
* block's strut. Every line box starts with the strut, the block's font at
|
|
3682
|
+
* its line height (CSS 2.1 10.8.1), and a layout has none: it makes a line
|
|
3683
|
+
* as tall as the text on it, at the block's line height as a multiple of
|
|
3684
|
+
* each face's natural one. So a line of `<small>` text alone came out as
|
|
3685
|
+
* short as the small text, and overlapped what followed. A paragraph with
|
|
3686
|
+
* such a line is made a line at a time instead, where `finishLine` puts
|
|
3687
|
+
* the strut on every line; one whose lines all hold text of the block's
|
|
3688
|
+
* own face is kept as it was laid out.
|
|
3689
|
+
*/
|
|
3690
|
+
function shortOfStrut(lines, strut,
|
|
3691
|
+
/** The first line's, where it holds more: a marker's room. */
|
|
3692
|
+
first = null) {
|
|
3693
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
3694
|
+
const line = lines[i];
|
|
3695
|
+
const holds = i === 0 && first ? first : strut;
|
|
3696
|
+
if (!holds)
|
|
3697
|
+
continue;
|
|
3698
|
+
// its room as the strut's is worked out, whichever baseline the layout
|
|
3699
|
+
// it is drawn with could keep (`layoutLift`)
|
|
3700
|
+
const text = line.texts.length === 1 ? line.texts[0] : null;
|
|
3701
|
+
const natural = text?.layout.lines[text.layoutLine];
|
|
3702
|
+
const ascent = natural ? lineAscent(natural) : line.baseline;
|
|
3703
|
+
if (ascent < holds.ascent - 0.5 ||
|
|
3704
|
+
line.height - ascent < holds.descent - 0.5) {
|
|
3705
|
+
return true;
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
return false;
|
|
3709
|
+
}
|
|
3710
|
+
/** A strut with room besides it about the same baseline, or the strut
|
|
3711
|
+
* where there is none. */
|
|
3712
|
+
function withRoom(strut, room) {
|
|
3713
|
+
if (!room)
|
|
3714
|
+
return strut;
|
|
3715
|
+
if (!strut)
|
|
3716
|
+
return room;
|
|
3717
|
+
return {
|
|
3718
|
+
ascent: Math.max(strut.ascent, room.ascent),
|
|
3719
|
+
descent: Math.max(strut.descent, room.descent),
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
/** The share of the size a synthesized small capital is set at, rounded to
|
|
3723
|
+
* a whole pixel as Blink rounds it (`SimpleFontData`'s scaled font data):
|
|
3724
|
+
* CSS Fonts 4 (6.2) leaves the size to the user agent. */
|
|
3725
|
+
const SMALL_CAPS_SCALE = 0.7;
|
|
3726
|
+
/**
|
|
3727
|
+
* Small capitals a face does not have, made of its capitals set smaller
|
|
3728
|
+
* (CSS Fonts 4, 6.2): where `font-variant-caps` asks for `smcp` — or
|
|
3729
|
+
* `c2sc` too, for `all-small-caps` — and the face does not answer it,
|
|
3730
|
+
* each letter that would be a small capital is its capital at 70% of the
|
|
3731
|
+
* size, in a run of its own: a lower-case letter, and for `all-small-caps`
|
|
3732
|
+
* the rest of the text too. Under `small-caps` what has no case — a space,
|
|
3733
|
+
* a digit, a mark of punctuation — keeps its size, as Blink keeps it. Only
|
|
3734
|
+
* a letter whose capital is one character is made one, so every offset
|
|
3735
|
+
* holds.
|
|
3736
|
+
*/
|
|
3737
|
+
function synthesizeSmallCaps(items, fonts) {
|
|
3738
|
+
for (let i = 0; i < items.length; i++) {
|
|
3739
|
+
const item = items[i];
|
|
3740
|
+
if (item.kind !== 'text' || item.control)
|
|
3741
|
+
continue;
|
|
3742
|
+
// `font-variant-caps`'s, which is synthesized where the face has none;
|
|
3743
|
+
// not `font-feature-settings`', which asks for the feature as it is
|
|
3744
|
+
const caps = item.box.style.fontVariantCaps;
|
|
3745
|
+
const features = item.run.features;
|
|
3746
|
+
if (!caps.includes('smcp') || !features?.smcp)
|
|
3747
|
+
continue;
|
|
3748
|
+
const all = caps.includes('c2sc');
|
|
3749
|
+
if (hasSmallCaps(fonts, item.run, all))
|
|
3750
|
+
continue;
|
|
3751
|
+
const pieces = smallCapsPieces(item, all, withoutCaps(features));
|
|
3752
|
+
if (!pieces)
|
|
3753
|
+
continue;
|
|
3754
|
+
items.splice(i, 1, ...pieces);
|
|
3755
|
+
i += pieces.length - 1;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
/** A text item cut where its letters turn to synthesized small capitals
|
|
3759
|
+
* and back, or null where none does. */
|
|
3760
|
+
function smallCapsPieces(item, all, features) {
|
|
3761
|
+
const text = item.run.text;
|
|
3762
|
+
const pieces = [];
|
|
3763
|
+
let from = 0;
|
|
3764
|
+
let small = false;
|
|
3765
|
+
let capitals = '';
|
|
3766
|
+
const cut = (to) => {
|
|
3767
|
+
if (to === from)
|
|
3768
|
+
return;
|
|
3769
|
+
const run = small
|
|
3770
|
+
? {
|
|
3771
|
+
...item.run,
|
|
3772
|
+
text: capitals,
|
|
3773
|
+
size: Math.round((item.run.size ?? 16) * SMALL_CAPS_SCALE),
|
|
3774
|
+
}
|
|
3775
|
+
: { ...item.run, text: text.slice(from, to) };
|
|
3776
|
+
if (small) {
|
|
3777
|
+
if (features)
|
|
3778
|
+
run.features = features;
|
|
3779
|
+
else
|
|
3780
|
+
delete run.features;
|
|
3781
|
+
}
|
|
3782
|
+
pieces.push({ ...item, run, start: item.start + from, length: to - from });
|
|
3783
|
+
from = to;
|
|
3784
|
+
capitals = '';
|
|
3785
|
+
};
|
|
3786
|
+
let any = false;
|
|
3787
|
+
for (let at = 0; at < text.length;) {
|
|
3788
|
+
const ch = String.fromCodePoint(text.codePointAt(at));
|
|
3789
|
+
const upper = ch.toUpperCase();
|
|
3790
|
+
// everything, for `all-small-caps`: its spaces are small too
|
|
3791
|
+
const becomes = (all || upper !== ch) && upper.length === ch.length;
|
|
3792
|
+
if (becomes !== small) {
|
|
3793
|
+
cut(at);
|
|
3794
|
+
small = becomes;
|
|
3795
|
+
}
|
|
3796
|
+
if (becomes) {
|
|
3797
|
+
capitals += upper;
|
|
3798
|
+
any = true;
|
|
3799
|
+
}
|
|
3800
|
+
at += ch.length;
|
|
3801
|
+
}
|
|
3802
|
+
cut(text.length);
|
|
3803
|
+
return any ? pieces : null;
|
|
3804
|
+
}
|
|
3805
|
+
/** Features without the small capitals a synthesis stands in for, one
|
|
3806
|
+
* object for each set, as `featuresOf` keeps them. */
|
|
3807
|
+
function withoutCaps(features) {
|
|
3808
|
+
let kept = WITHOUT_CAPS.get(features);
|
|
3809
|
+
if (kept === undefined) {
|
|
3810
|
+
const out = {};
|
|
3811
|
+
for (const [tag, value] of Object.entries(features)) {
|
|
3812
|
+
if (tag !== 'smcp' && tag !== 'c2sc')
|
|
3813
|
+
out[tag] = value;
|
|
3814
|
+
}
|
|
3815
|
+
kept = Object.keys(out).length ? out : null;
|
|
3816
|
+
WITHOUT_CAPS.set(features, kept);
|
|
3817
|
+
}
|
|
3818
|
+
return kept;
|
|
3819
|
+
}
|
|
3820
|
+
const WITHOUT_CAPS = new WeakMap();
|
|
3821
|
+
/**
|
|
3822
|
+
* Whether a run's face has small capitals of its own — and capitals to
|
|
3823
|
+
* small capitals, for `all-small-caps`: the alphabet set with the feature
|
|
3824
|
+
* and without it comes out another width where it does. Asked of the
|
|
3825
|
+
* engine's layout rather than of the font's tables, which a CoreText face
|
|
3826
|
+
* does not hand over. Kept per face.
|
|
3827
|
+
*/
|
|
3828
|
+
function hasSmallCaps(fonts, run, all) {
|
|
3829
|
+
let known = SMALL_CAPS.get(fonts);
|
|
3830
|
+
if (!known)
|
|
3831
|
+
SMALL_CAPS.set(fonts, (known = new Map()));
|
|
3832
|
+
const key = `${run.family}|${run.weight}|${run.style}|${all}`;
|
|
3833
|
+
let has = known.get(key);
|
|
3834
|
+
if (has === undefined) {
|
|
3835
|
+
const face = {
|
|
3836
|
+
family: run.family,
|
|
3837
|
+
weight: run.weight,
|
|
3838
|
+
style: run.style,
|
|
3839
|
+
size: 16,
|
|
3840
|
+
};
|
|
3841
|
+
const width = (text, features) => fonts.layout([{ text, ...face, ...(features ? { features } : null) }], face, {}).width;
|
|
3842
|
+
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
|
3843
|
+
has = Math.abs(width(lower, { smcp: 1 }) - width(lower, null)) > 0.01;
|
|
3844
|
+
if (has && all) {
|
|
3845
|
+
const upper = lower.toUpperCase();
|
|
3846
|
+
has = Math.abs(width(upper, { c2sc: 1 }) - width(upper, null)) > 0.01;
|
|
3847
|
+
}
|
|
3848
|
+
known.set(key, has);
|
|
3849
|
+
}
|
|
3850
|
+
return has;
|
|
3851
|
+
}
|
|
3852
|
+
const SMALL_CAPS = new WeakMap();
|
|
2764
3853
|
/**
|
|
2765
3854
|
* The OpenType features a style's text is shaped with: the `font-variant`
|
|
2766
3855
|
* longhands' and `font-kerning`'s, then `font-feature-settings`, which has
|
|
@@ -2872,11 +3961,15 @@ class SpanMap {
|
|
|
2872
3961
|
* a spacer, a bidi control. */
|
|
2873
3962
|
_gaps = null;
|
|
2874
3963
|
laidOut = 0;
|
|
2875
|
-
add(documentStart, length, box = null
|
|
3964
|
+
add(documentStart, length, box = null,
|
|
3965
|
+
/** No text of the document's, though a box's: a bidi control a
|
|
3966
|
+
* level's layout is wrapped in (`levelPieces`), which answers for
|
|
3967
|
+
* the element around it. */
|
|
3968
|
+
gap = box === null) {
|
|
2876
3969
|
this._laid.push(this.laidOut);
|
|
2877
3970
|
this._doc.push(documentStart);
|
|
2878
3971
|
this._boxes.push(box);
|
|
2879
|
-
if (
|
|
3972
|
+
if (gap) {
|
|
2880
3973
|
this._gaps ??= [];
|
|
2881
3974
|
for (let i = 0; i < length; i += 1)
|
|
2882
3975
|
this._gaps.push(this.laidOut + i);
|
|
@@ -2893,6 +3986,23 @@ class SpanMap {
|
|
|
2893
3986
|
if (gaps.length)
|
|
2894
3987
|
text.gaps = gaps;
|
|
2895
3988
|
}
|
|
3989
|
+
/** Whether an offset in the laid-out text is no text of the
|
|
3990
|
+
* document's: a spacer, a bidi control. */
|
|
3991
|
+
isGap(offset) {
|
|
3992
|
+
const gaps = this._gaps;
|
|
3993
|
+
if (!gaps)
|
|
3994
|
+
return false;
|
|
3995
|
+
let lo = 0;
|
|
3996
|
+
let hi = gaps.length;
|
|
3997
|
+
while (lo < hi) {
|
|
3998
|
+
const mid = (lo + hi) >> 1;
|
|
3999
|
+
if (gaps[mid] < offset)
|
|
4000
|
+
lo = mid + 1;
|
|
4001
|
+
else
|
|
4002
|
+
hi = mid;
|
|
4003
|
+
}
|
|
4004
|
+
return gaps[lo] === offset;
|
|
4005
|
+
}
|
|
2896
4006
|
/** The text box whose text an offset in the laid-out text is. */
|
|
2897
4007
|
boxAt(offset) {
|
|
2898
4008
|
const i = this._entryAt(offset);
|
|
@@ -3023,6 +4133,165 @@ export function hungSpaces(fonts, run) {
|
|
|
3023
4133
|
}
|
|
3024
4134
|
return hung;
|
|
3025
4135
|
}
|
|
4136
|
+
/** Where each run starts in their joined text. */
|
|
4137
|
+
function runStarts(runs) {
|
|
4138
|
+
const starts = [];
|
|
4139
|
+
let at = 0;
|
|
4140
|
+
for (const run of runs) {
|
|
4141
|
+
starts.push(at);
|
|
4142
|
+
at += run.text.length;
|
|
4143
|
+
}
|
|
4144
|
+
return starts;
|
|
4145
|
+
}
|
|
4146
|
+
/** The run a code-unit offset in the runs' joined text falls in. */
|
|
4147
|
+
function runIndexAt(starts, offset) {
|
|
4148
|
+
let lo = 0;
|
|
4149
|
+
let hi = starts.length - 1;
|
|
4150
|
+
while (lo < hi) {
|
|
4151
|
+
const mid = (lo + hi + 1) >> 1;
|
|
4152
|
+
if (starts[mid] <= offset)
|
|
4153
|
+
lo = mid;
|
|
4154
|
+
else
|
|
4155
|
+
hi = mid - 1;
|
|
4156
|
+
}
|
|
4157
|
+
return lo;
|
|
4158
|
+
}
|
|
4159
|
+
/**
|
|
4160
|
+
* The spaces `pre-wrap` keeps that a line ends on (CSS Text 3, 4.1.3), in
|
|
4161
|
+
* logical order and each with its advance, and whether a forced break
|
|
4162
|
+
* follows them. An engine strips a line's trailing white space, which is
|
|
4163
|
+
* right for the collapsible spaces CSS removes there, and wrong for these.
|
|
4164
|
+
*/
|
|
4165
|
+
function keptAtEnd(natural, runs, starts, spans, fonts) {
|
|
4166
|
+
const boxAt = spans.boxAt;
|
|
4167
|
+
if (!boxAt || !runs.length)
|
|
4168
|
+
return { found: null, forced: false };
|
|
4169
|
+
const charAt = (offset) => {
|
|
4170
|
+
const k = runIndexAt(starts, offset);
|
|
4171
|
+
return runs[k].text.charCodeAt(offset - starts[k]);
|
|
4172
|
+
};
|
|
4173
|
+
let end = natural.end;
|
|
4174
|
+
let forced = false;
|
|
4175
|
+
if (end > natural.start && charAt(end - 1) === 10) {
|
|
4176
|
+
forced = true;
|
|
4177
|
+
end -= 1;
|
|
4178
|
+
}
|
|
4179
|
+
let found = null;
|
|
4180
|
+
let kept = false;
|
|
4181
|
+
for (let at = end - 1; at >= natural.start; at -= 1) {
|
|
4182
|
+
const c = charAt(at);
|
|
4183
|
+
if (isControl(c))
|
|
4184
|
+
continue;
|
|
4185
|
+
if (c !== 0x20)
|
|
4186
|
+
break;
|
|
4187
|
+
// a collapsible space after the last kept one is removed (4.1.3), and
|
|
4188
|
+
// one before a kept one hangs with it
|
|
4189
|
+
const keeps = boxAt.call(spans, at)?.style.whiteSpace === 'pre-wrap';
|
|
4190
|
+
if (!keeps && !kept)
|
|
4191
|
+
continue;
|
|
4192
|
+
kept = true;
|
|
4193
|
+
const run = runs[runIndexAt(starts, at)];
|
|
4194
|
+
(found ??= []).push({ at, x: 0, width: spaceAdvance(fonts, run) });
|
|
4195
|
+
}
|
|
4196
|
+
found?.reverse();
|
|
4197
|
+
return { found, forced };
|
|
4198
|
+
}
|
|
4199
|
+
/**
|
|
4200
|
+
* The spaces `pre-wrap` keeps that a text's line ends on, which the engine
|
|
4201
|
+
* hung (`keptAtEnd`): past the line's end, to its left where the layout
|
|
4202
|
+
* reads right to left, and their inline box's, whose background and border
|
|
4203
|
+
* cover them, as a browser draws them. Noted on the text (`LineText.hung`),
|
|
4204
|
+
* and their advance answered, with whether a forced break follows them.
|
|
4205
|
+
*/
|
|
4206
|
+
function hangPreserved(text, runs, starts, fonts, rtl) {
|
|
4207
|
+
const natural = text.layout.lines[text.layoutLine];
|
|
4208
|
+
if (!natural)
|
|
4209
|
+
return { total: 0, forced: false };
|
|
4210
|
+
const { found, forced } = keptAtEnd(natural, runs, starts, text.spans, fonts);
|
|
4211
|
+
if (!found)
|
|
4212
|
+
return { total: 0, forced };
|
|
4213
|
+
text.hung = found;
|
|
4214
|
+
placeHung(text, rtl);
|
|
4215
|
+
let total = 0;
|
|
4216
|
+
for (const space of found)
|
|
4217
|
+
total += space.width;
|
|
4218
|
+
return { total, forced };
|
|
4219
|
+
}
|
|
4220
|
+
const NOTHING_HUNG = { total: 0, forced: false };
|
|
4221
|
+
/** Put a text's hung spaces beside its line's end, logical order outward:
|
|
4222
|
+
* to the right of its glyphs, or to their left. */
|
|
4223
|
+
function placeHung(text, left) {
|
|
4224
|
+
const natural = text.layout.lines[text.layoutLine];
|
|
4225
|
+
if (!text.hung || !natural)
|
|
4226
|
+
return;
|
|
4227
|
+
let x = left ? natural.x : natural.x + natural.width;
|
|
4228
|
+
for (const space of text.hung) {
|
|
4229
|
+
if (left)
|
|
4230
|
+
x -= space.width;
|
|
4231
|
+
space.x = x;
|
|
4232
|
+
if (!left)
|
|
4233
|
+
x += space.width;
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* The runs of a layout with the spaces `pre-wrap` keeps before a forced
|
|
4238
|
+
* break held where they fit (`keptAtEnd`) — a kept newline, or the block's
|
|
4239
|
+
* end after the last line where `ends` — or null where none are. They hang
|
|
4240
|
+
* there only where they do not fit (CSS Text 3, 4.1.3), so they take room,
|
|
4241
|
+
* which the engine gives them when they are handed over as no-break
|
|
4242
|
+
* spaces, as `pre`'s are (`heldText`): it measures them, and aligns the
|
|
4243
|
+
* line with them. Laid out again, the lines break where they did, since
|
|
4244
|
+
* the spaces fit where they are. A line of a layout cannot be moved over
|
|
4245
|
+
* for them afterwards: its lines are drawn in one draw, from one place.
|
|
4246
|
+
*/
|
|
4247
|
+
function holdAtBreaks(layout, runs, spans, fonts, width, ends) {
|
|
4248
|
+
const starts = runStarts(runs);
|
|
4249
|
+
let held = null;
|
|
4250
|
+
const lines = layout.lines;
|
|
4251
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
4252
|
+
const natural = lines[i];
|
|
4253
|
+
const { found, forced } = keptAtEnd(natural, runs, starts, spans, fonts);
|
|
4254
|
+
if (!found || !(forced || (ends && i === lines.length - 1)))
|
|
4255
|
+
continue;
|
|
4256
|
+
let free = width - natural.width;
|
|
4257
|
+
for (const space of found) {
|
|
4258
|
+
if (space.width > free + 0.01)
|
|
4259
|
+
break;
|
|
4260
|
+
free -= space.width;
|
|
4261
|
+
(held ??= []).push(space.at);
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
if (!held)
|
|
4265
|
+
return null;
|
|
4266
|
+
held.sort((a, b) => a - b);
|
|
4267
|
+
const out = runs.slice();
|
|
4268
|
+
let next = 0;
|
|
4269
|
+
for (let k = 0; k < runs.length && next < held.length; k += 1) {
|
|
4270
|
+
const start = starts[k];
|
|
4271
|
+
const end = start + runs[k].text.length;
|
|
4272
|
+
if (held[next] >= end)
|
|
4273
|
+
continue;
|
|
4274
|
+
const chars = runs[k].text.split('');
|
|
4275
|
+
while (next < held.length && held[next] < end) {
|
|
4276
|
+
chars[held[next] - start] = '\u00a0';
|
|
4277
|
+
next += 1;
|
|
4278
|
+
}
|
|
4279
|
+
out[k] = { ...runs[k], text: chars.join('') };
|
|
4280
|
+
}
|
|
4281
|
+
return out;
|
|
4282
|
+
}
|
|
4283
|
+
/** The spaces `pre-wrap` keeps that the lines one layout made hung at
|
|
4284
|
+
* their ends (`hangPreserved`), noted on each line's text. */
|
|
4285
|
+
function hangLines(lines, from, runs, fonts, rtl) {
|
|
4286
|
+
let starts = null;
|
|
4287
|
+
for (let i = from; i < lines.length; i += 1) {
|
|
4288
|
+
const text = lines[i].texts[0];
|
|
4289
|
+
if (!text)
|
|
4290
|
+
continue;
|
|
4291
|
+
starts ??= runStarts(runs);
|
|
4292
|
+
hangPreserved(text, runs, starts, fonts, rtl);
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
3026
4295
|
/**
|
|
3027
4296
|
* A clamped paragraph with an ellipsis at the end of its last line (CSS
|
|
3028
4297
|
* Overflow 4, 4.2): its text up to where that line breaks in the room the
|
|
@@ -3249,14 +4518,17 @@ function unwrappedPlacer(style, width, last = true) {
|
|
|
3249
4518
|
}
|
|
3250
4519
|
/**
|
|
3251
4520
|
* The spacing a space is given to be laid out as one that may be spaced
|
|
3252
|
-
* apart, too little to move a glyph. It is not nothing, because
|
|
3253
|
-
*
|
|
3254
|
-
* the same space inside its run:
|
|
3255
|
-
*
|
|
3256
|
-
*
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3259
|
-
*
|
|
4521
|
+
* apart, too little to move a glyph. It is not nothing, because CoreText
|
|
4522
|
+
* does not measure a space that is a spaced run of its own as it measures
|
|
4523
|
+
* the same space inside its run: it spaces a glyph with its kerning
|
|
4524
|
+
* attribute, which takes the place of the font's pairs. A line justified
|
|
4525
|
+
* from a measure of its spaces as they were came out wider than its box
|
|
4526
|
+
* and broke a word early: on macOS, in Helvetica, half of a paragraph did.
|
|
4527
|
+
* ntk keeps the pairs a spaced space makes with the letters beside it
|
|
4528
|
+
* (Arial's space and `T`, its `L` and space) where the run is marked
|
|
4529
|
+
* `kernAcross` — spacing that justifies a line is in addition to kerning
|
|
4530
|
+
* (CSS Text 3, 7.2) — so there the measure is the unjustified line's, as
|
|
4531
|
+
* a browser's is.
|
|
3260
4532
|
*/
|
|
3261
4533
|
const HAIR = 1e-6;
|
|
3262
4534
|
/** The runs with each space a run of its own, spaced a hair apart. */
|
|
@@ -3275,6 +4547,7 @@ function spacedApart(runs) {
|
|
|
3275
4547
|
...run,
|
|
3276
4548
|
text: text[at],
|
|
3277
4549
|
letterSpacing: (run.letterSpacing ?? 0) + HAIR,
|
|
4550
|
+
kernAcross: true,
|
|
3278
4551
|
});
|
|
3279
4552
|
piece = at + 1;
|
|
3280
4553
|
}
|
|
@@ -3349,6 +4622,7 @@ function widenedAt(runs, extra) {
|
|
|
3349
4622
|
...run,
|
|
3350
4623
|
text: text[at],
|
|
3351
4624
|
letterSpacing: (run.letterSpacing ?? 0) + add,
|
|
4625
|
+
kernAcross: true,
|
|
3352
4626
|
});
|
|
3353
4627
|
piece = at + 1;
|
|
3354
4628
|
}
|
|
@@ -3595,8 +4869,8 @@ function naturalLineHeight(fonts, style) {
|
|
|
3595
4869
|
}
|
|
3596
4870
|
/**
|
|
3597
4871
|
* A block's strut: the ascent and descent of a line of nothing in its face,
|
|
3598
|
-
* the
|
|
3599
|
-
*
|
|
4872
|
+
* the leading its `line-height` adds shared above and below as
|
|
4873
|
+
* `ascentOnLine` shares it (CSS 2.1 10.8.1).
|
|
3600
4874
|
*/
|
|
3601
4875
|
export function strutOf(fonts, style) {
|
|
3602
4876
|
// text set at no size takes no room, and CoreText reads a size of 0 as
|
|
@@ -3609,8 +4883,57 @@ export function strutOf(fonts, style) {
|
|
|
3609
4883
|
: style.lineHeightIsLength
|
|
3610
4884
|
? style.lineHeight
|
|
3611
4885
|
: style.lineHeight * style.fontSize;
|
|
3612
|
-
const
|
|
3613
|
-
return { ascent
|
|
4886
|
+
const ascent = ascentOnLine(face.ascent, face.descent, target);
|
|
4887
|
+
return { ascent, descent: target - ascent };
|
|
4888
|
+
}
|
|
4889
|
+
/**
|
|
4890
|
+
* How far above its baseline an inline box's room on a line reaches: its
|
|
4891
|
+
* face's ascent and half its leading (CSS 2.1 10.8.1, CSS Inline 3 5.3),
|
|
4892
|
+
* rounded down to a whole pixel, the rest of the leading going below.
|
|
4893
|
+
*
|
|
4894
|
+
* The spec's halves are exact, and where one is not a whole pixel which
|
|
4895
|
+
* side of the grid the text lands on is the user agent's. Blink rounds the
|
|
4896
|
+
* half above down (`CalculateLeadingSpace`, core/layout/inline/
|
|
4897
|
+
* line_utils.cc): a 13px Arial on an 18px line has 1px of leading over its
|
|
4898
|
+
* 15 and 2px under. Split evenly, that box reached half a pixel above the
|
|
4899
|
+
* 12px strut beside it, whose 4px of leading divides, and every such line
|
|
4900
|
+
* came out 18.5px tall.
|
|
4901
|
+
*/
|
|
4902
|
+
export function ascentOnLine(ascent, descent, height) {
|
|
4903
|
+
return ascent + Math.floor((height - ascent - descent) / 2 + 1e-6);
|
|
4904
|
+
}
|
|
4905
|
+
/**
|
|
4906
|
+
* How far above its baseline a text engine's line reaches, its leading
|
|
4907
|
+
* shared as `ascentOnLine` shares it. The engines split it evenly (the
|
|
4908
|
+
* contract every text engine owes, a baseline `(height - ascent -
|
|
4909
|
+
* descent) / 2 + ascent` down), so a line on its own sits up to a pixel
|
|
4910
|
+
* lower than a browser's; one that did not say how tall its face is keeps
|
|
4911
|
+
* the engine's.
|
|
4912
|
+
*/
|
|
4913
|
+
function lineAscent(natural) {
|
|
4914
|
+
const { ascent, descent } = natural;
|
|
4915
|
+
if (!Number.isFinite(ascent) || !Number.isFinite(descent)) {
|
|
4916
|
+
return natural.baseline - natural.y;
|
|
4917
|
+
}
|
|
4918
|
+
return ascentOnLine(ascent, descent, natural.height);
|
|
4919
|
+
}
|
|
4920
|
+
/**
|
|
4921
|
+
* How far up a layout drawn whole moves for each of its lines' baselines
|
|
4922
|
+
* to be where `lineAscent` puts it. A layout is drawn in one batch, from
|
|
4923
|
+
* one origin, so it moves by one amount or not at all: where its lines
|
|
4924
|
+
* would move by different amounts — a face of other metrics on one — it
|
|
4925
|
+
* keeps the engine's baselines, which are less than a pixel out.
|
|
4926
|
+
*/
|
|
4927
|
+
function layoutLift(layout) {
|
|
4928
|
+
let lift = NaN;
|
|
4929
|
+
for (const natural of layout.lines) {
|
|
4930
|
+
const own = natural.baseline - natural.y - lineAscent(natural);
|
|
4931
|
+
if (Number.isNaN(lift))
|
|
4932
|
+
lift = own;
|
|
4933
|
+
else if (Math.abs(own - lift) > 1e-6)
|
|
4934
|
+
return 0;
|
|
4935
|
+
}
|
|
4936
|
+
return Number.isNaN(lift) ? 0 : lift;
|
|
3614
4937
|
}
|
|
3615
4938
|
/**
|
|
3616
4939
|
* The raises `vertical-align` gives a paragraph's inline boxes (CSS 2.1
|
|
@@ -3786,12 +5109,18 @@ const NO_LIFT = { raise: 0, edge: null, lead: false };
|
|
|
3786
5109
|
* and size is past at once, and any other answer is kept on its style.
|
|
3787
5110
|
*/
|
|
3788
5111
|
function ownsLeading(fonts, block, style) {
|
|
3789
|
-
//
|
|
3790
|
-
// paragraph is thick with <strong>, <em> and <a
|
|
5112
|
+
// the block's own face is past at once. A bold or an italic one is not:
|
|
5113
|
+
// a paragraph is thick with <strong>, <em> and <a>, and most families'
|
|
5114
|
+
// faces share their line metrics, but not every one's — Helvetica Neue
|
|
5115
|
+
// Bold reaches 0.975em above its baseline to the regular's 0.952, and
|
|
5116
|
+
// Blink, which rounds each to a whole pixel, sets a 12px bold word on a
|
|
5117
|
+
// 19.2px line a pixel taller. It is measured once for its style, below.
|
|
3791
5118
|
if (style.lineHeight === block.lineHeight &&
|
|
3792
5119
|
style.lineHeightIsLength === block.lineHeightIsLength &&
|
|
3793
5120
|
style.fontSize === block.fontSize &&
|
|
3794
|
-
style.fontFamily === block.fontFamily
|
|
5121
|
+
style.fontFamily === block.fontFamily &&
|
|
5122
|
+
style.fontWeight === block.fontWeight &&
|
|
5123
|
+
style.fontStyle === block.fontStyle) {
|
|
3795
5124
|
return false;
|
|
3796
5125
|
}
|
|
3797
5126
|
const known = LEADS.get(style);
|
|
@@ -3904,7 +5233,7 @@ function tooNarrow(runs, end, width, room) {
|
|
|
3904
5233
|
}
|
|
3905
5234
|
if (WORD_CHAR.test(before) && WORD_CHAR.test(after))
|
|
3906
5235
|
return true;
|
|
3907
|
-
return width > room +
|
|
5236
|
+
return width > room + FIT_SLACK && before !== '';
|
|
3908
5237
|
}
|
|
3909
5238
|
const WORD_CHAR = /^(?![\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}])[\p{L}\p{N}\p{M}]$/u;
|
|
3910
5239
|
function bandAt(options, y, height) {
|