render-tag 0.1.32 → 0.1.34
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/README.md +6 -6
- package/lib/css-resolver.d.ts +1 -4
- package/lib/css-resolver.d.ts.map +1 -1
- package/lib/css-resolver.js +116 -40
- package/lib/css-resolver.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -3
- package/lib/index.js.map +1 -1
- package/lib/layout.d.ts +22 -0
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +639 -233
- package/lib/layout.js.map +1 -1
- package/lib/path/index.d.ts.map +1 -1
- package/lib/path/index.js +1 -2
- package/lib/path/index.js.map +1 -1
- package/lib/render-tag.umd.js +7 -8
- package/lib/render-tag.umd.js.map +1 -1
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +116 -21
- package/lib/render.js.map +1 -1
- package/lib/types.d.ts +47 -7
- package/lib/types.d.ts.map +1 -1
- package/package.json +37 -7
package/lib/layout.js
CHANGED
|
@@ -5,6 +5,8 @@ let _debug;
|
|
|
5
5
|
// Lines emitted during layout. Reset at the start of buildLayoutTree();
|
|
6
6
|
// layoutInlineContent appends one entry per committed line.
|
|
7
7
|
let _lines = [];
|
|
8
|
+
const _minContentCache = new Map();
|
|
9
|
+
const _maxContentCache = new Map();
|
|
8
10
|
// ─── measureText width cache ──────────────────────────────────────────
|
|
9
11
|
// Caches ctx.measureText(text).width keyed by "font\0text".
|
|
10
12
|
// Cleared at the start of each buildLayoutTree() call.
|
|
@@ -21,17 +23,21 @@ function cachedMeasureWidth(ctx, text) {
|
|
|
21
23
|
return w;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
|
-
*
|
|
26
|
+
* Would every glyph on this line be measured under one canvas state? Only then
|
|
27
|
+
* can the line be re-measured as a single string. Keyed on what `applyFont`
|
|
28
|
+
* and `formatLetterSpacing` actually set, not on the raw declarations —
|
|
29
|
+
* `font-kerning: auto` and `normal` are one state on the canvas.
|
|
25
30
|
*/
|
|
26
|
-
function
|
|
27
|
-
let
|
|
31
|
+
function hasMixedTextMetrics(words) {
|
|
32
|
+
let metrics = '';
|
|
28
33
|
for (const w of words) {
|
|
29
34
|
if (!w.text || w.isSpace)
|
|
30
35
|
continue;
|
|
31
|
-
const
|
|
32
|
-
|
|
36
|
+
const key = `${buildCanvasFont(w.style)}|${formatLetterSpacing(w.style.letterSpacing)}` +
|
|
37
|
+
`|${canvasKerning(w.style)}`;
|
|
38
|
+
if (metrics && key !== metrics)
|
|
33
39
|
return true;
|
|
34
|
-
|
|
40
|
+
metrics = key;
|
|
35
41
|
}
|
|
36
42
|
return false;
|
|
37
43
|
}
|
|
@@ -41,7 +47,11 @@ function hasMixedFonts(words) {
|
|
|
41
47
|
*/
|
|
42
48
|
export function applyFont(ctx, style) {
|
|
43
49
|
ctx.font = buildCanvasFont(style);
|
|
44
|
-
ctx.fontKerning = style
|
|
50
|
+
ctx.fontKerning = canvasKerning(style);
|
|
51
|
+
}
|
|
52
|
+
/** The `ctx.fontKerning` value a style resolves to. */
|
|
53
|
+
function canvasKerning(style) {
|
|
54
|
+
return style.fontKerning === 'none' ? 'none' : 'normal';
|
|
45
55
|
}
|
|
46
56
|
/** Format a letter-spacing value (px) as a canvas `ctx.letterSpacing` string. */
|
|
47
57
|
function formatLetterSpacing(value) {
|
|
@@ -174,6 +184,10 @@ function getLineHeight(ctx, style, useBulletProbe = false) {
|
|
|
174
184
|
const UA = typeof navigator === 'undefined' ? '' : navigator.userAgent;
|
|
175
185
|
const IS_GECKO = /\bGecko\/\d/.test(UA);
|
|
176
186
|
const IS_SAFARI = /AppleWebKit/.test(UA) && !/Chrome\/\d/.test(UA) && !/\bjsdom\//.test(UA);
|
|
187
|
+
/** Blink (and server-side rendering, whose documented target is Blink) can
|
|
188
|
+
* paint ordinary LTR words as one shaped source run without moving its DOM
|
|
189
|
+
* raster. Gecko and WebKit keep the established word paint path. */
|
|
190
|
+
export const BLINK_TEXT_RUN_SHAPING = !IS_GECKO && !IS_SAFARI;
|
|
177
191
|
/**
|
|
178
192
|
* True where the engine floors a line's baseline onto a whole CSS pixel.
|
|
179
193
|
*
|
|
@@ -203,6 +217,31 @@ export function lineBaselineOffset(lineHeight, ascent, descent) {
|
|
|
203
217
|
const exact = (lineHeight - (ascent + descent)) / 2 + ascent;
|
|
204
218
|
return FLOORS_LINE_BASELINE ? Math.floor(exact) : exact;
|
|
205
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Tab-stop metrics for a block, the way Chrome sizes them. Tab stops follow
|
|
222
|
+
* the BLOCK's style, not the inline run the tab sits in: the interval is
|
|
223
|
+
* tab-size(8) × the block font's space advance — measured with
|
|
224
|
+
* letter-spacing off — plus the block's letter- and word-spacing per stop
|
|
225
|
+
* (css-text-3 §tab-size), verified against the DOM (a tab inside a bold span
|
|
226
|
+
* still uses the regular-weight space). `halfSpace` carries Blink's skip
|
|
227
|
+
* rule: when the next stop is closer than half a space width, the tab
|
|
228
|
+
* advances to the stop after it (Font::TabWidth).
|
|
229
|
+
*
|
|
230
|
+
* Public API for the same reason as `lineBaselineOffset`: a renderer that
|
|
231
|
+
* re-flows text beside a render-tag canvas needs identical stops. Call it
|
|
232
|
+
* rather than restate it, or the two drift. Mutates ctx font state.
|
|
233
|
+
*/
|
|
234
|
+
export function tabStopMetrics(ctx, style) {
|
|
235
|
+
applyFont(ctx, style);
|
|
236
|
+
const prevLetterSpacing = ctx.letterSpacing;
|
|
237
|
+
ctx.letterSpacing = '0px';
|
|
238
|
+
const spaceWidth = cachedMeasureWidth(ctx, ' ');
|
|
239
|
+
ctx.letterSpacing = prevLetterSpacing;
|
|
240
|
+
return {
|
|
241
|
+
interval: (spaceWidth + (style.letterSpacing || 0) + (style.wordSpacing || 0)) * 8,
|
|
242
|
+
halfSpace: spaceWidth / 2,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
206
245
|
/**
|
|
207
246
|
* The vertical space an inline-block's margin box adds around its content, over
|
|
208
247
|
* and above the font's own leading. Written once because the wrap pass grows
|
|
@@ -293,6 +332,15 @@ export function getFontMetrics(ctx, style) {
|
|
|
293
332
|
*/
|
|
294
333
|
function verticalAlignShift(va, ctx, style, parentStyle, useBulletProbe) {
|
|
295
334
|
switch (va) {
|
|
335
|
+
// Blink and WebKit share the fraction (Blink's inline_box_state.cc:
|
|
336
|
+
// fontSize/3 + 1 for super, /5 + 1 for sub, from the PARENT box's size,
|
|
337
|
+
// no font metric involved); Gecko raises by 0.34em and lowers by 0.2em.
|
|
338
|
+
// Deliberately NOT Blink's LayoutUnit arithmetic (snap the size to the
|
|
339
|
+
// 1/64px grid, truncate the division): that matches Chrome's DOM layout
|
|
340
|
+
// rects exactly (measured, 18/18 samples 10-100px vs float's <=0.0125px
|
|
341
|
+
// residual — and WebKit divides in plain float), but the shift also feeds
|
|
342
|
+
// the line-box union, and quantizing it REGRESSED the sub/sup pixel
|
|
343
|
+
// baselines 0.5-2% on every font variant. The screenshot is the oracle.
|
|
296
344
|
case 'super':
|
|
297
345
|
return BLINK_SUPER_SUB
|
|
298
346
|
? -(parentStyle.fontSize / 3 + 1) : -parentStyle.fontSize * 0.34;
|
|
@@ -535,6 +583,7 @@ function collectTextRuns(node) {
|
|
|
535
583
|
// Store the full box info for atomic inline-block handling
|
|
536
584
|
boxOpen: n.style, // signals this is a boxed element
|
|
537
585
|
boxClose: n.style,
|
|
586
|
+
inlineBlock: n,
|
|
538
587
|
});
|
|
539
588
|
return;
|
|
540
589
|
}
|
|
@@ -648,7 +697,9 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
648
697
|
run.style.whiteSpace === 'break-spaces';
|
|
649
698
|
if (isPreserve) {
|
|
650
699
|
// Split on spaces and tabs, keeping delimiters
|
|
651
|
-
const words = text
|
|
700
|
+
const words = text
|
|
701
|
+
.split(/( +|\t)/)
|
|
702
|
+
.flatMap((word) => /^( +|\t)$/.test(word) ? [word] : splitHyphenated(word));
|
|
652
703
|
const tabStopInterval = cachedMeasureWidth(ctx, ' ') * 8; // CSS default: 8 spaces
|
|
653
704
|
for (const w of words) {
|
|
654
705
|
if (w === '')
|
|
@@ -689,9 +740,19 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
689
740
|
// or ":" (verified against the browser), so only "?" is split here. The
|
|
690
741
|
// "?" stays with the preceding fragment; a trailing "?" (no follower) is
|
|
691
742
|
// left intact. Fragments measure cumulatively so kerning stays accurate.
|
|
743
|
+
// The second alternative: a non-breaking space still permits a break
|
|
744
|
+
// BEFORE it when the preceding character is a hyphen or a break-after one
|
|
745
|
+
// (UAX #14 LB12a, `[^SP BA HY] x GL`). Measured against the DOM with
|
|
746
|
+
// `aaaaaaaaaa<c>\u00A0bbbbbbbbbb` at 120px/15px Open Sans: only "-",
|
|
747
|
+
// "|", "\u2013" and "\u2014" break there. Letters, "\u2026", ")", "\u00BB",
|
|
748
|
+
// "?", "/" and "," all keep the NBSP glued, so the set is exactly HY
|
|
749
|
+
// plus BA and nothing wider.
|
|
692
750
|
const words = text
|
|
693
751
|
.split(/([ \t\n\r\f\v]+)/)
|
|
694
|
-
.flatMap((w) => /^[ \t\n\r\f\v]+$/.test(w)
|
|
752
|
+
.flatMap((w) => /^[ \t\n\r\f\v]+$/.test(w)
|
|
753
|
+
? [w]
|
|
754
|
+
: w.split(/(?<=\?)(?=.)|(?<=[-|\u2013\u2014])(?=\u00A0)/))
|
|
755
|
+
.flatMap((word) => /^[ \t\n\r\f\v]+$/.test(word) ? [word] : splitHyphenated(word));
|
|
695
756
|
// Use cumulative measurement to avoid rounding error accumulation
|
|
696
757
|
// within a single text run. When cumState is provided (from \u200B/\u00AD
|
|
697
758
|
// split), continue from the previous cumulative position to preserve
|
|
@@ -809,6 +870,7 @@ function tokenizeRuns(ctx, runs) {
|
|
|
809
870
|
boxClose: run.boxClose,
|
|
810
871
|
clipStyle: run.clipStyle,
|
|
811
872
|
strokeImageStyle: run.strokeImageStyle,
|
|
873
|
+
inlineBlock: run.inlineBlock,
|
|
812
874
|
});
|
|
813
875
|
continue;
|
|
814
876
|
}
|
|
@@ -916,6 +978,13 @@ function graphemes(text) {
|
|
|
916
978
|
return seg ? [...seg.segment(text)].map((s) => s.segment) : [...text];
|
|
917
979
|
}
|
|
918
980
|
const EMOJI_PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
|
|
981
|
+
/**
|
|
982
|
+
* Exactly the characters `isEmojiCluster` can answer `true` for: something in
|
|
983
|
+
* the emoji planes (regional-indicator flags included), a ZWJ, or a VS16. A
|
|
984
|
+
* word without one of these cannot contain an emoji cluster, so this skips
|
|
985
|
+
* grapheme segmentation for the overwhelming majority of words.
|
|
986
|
+
*/
|
|
987
|
+
const EMOJI_CANDIDATE = /[\u{1F000}-\u{10FFFF}\u200D\uFE0F]/u;
|
|
919
988
|
/**
|
|
920
989
|
* Is this grapheme cluster an emoji that creates a line-break opportunity?
|
|
921
990
|
* Restricted to emoji-presentation clusters (emoji planes, regional-indicator
|
|
@@ -933,9 +1002,22 @@ function isEmojiCluster(s) {
|
|
|
933
1002
|
}
|
|
934
1003
|
return false;
|
|
935
1004
|
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Split after CSS hyphen break opportunities, preserving the hyphen. The
|
|
1007
|
+
* two-character lookbehind cannot match at index 1, which is what keeps a
|
|
1008
|
+
* leading hyphen attached to the word it starts.
|
|
1009
|
+
*/
|
|
1010
|
+
function splitHyphenated(text) {
|
|
1011
|
+
return text.split(/(?<=[^]-)/).filter(Boolean);
|
|
1012
|
+
}
|
|
936
1013
|
/**
|
|
937
1014
|
* Break a word into character-level pieces if it contains CJK/emoji or if
|
|
938
1015
|
* overflow-wrap: break-word is set and the word is too wide.
|
|
1016
|
+
*
|
|
1017
|
+
* `emergency` distinguishes the two reasons. CJK and emoji carry their own
|
|
1018
|
+
* break opportunities, so those splits are ordinary and fill the current line.
|
|
1019
|
+
* `overflow-wrap: break-word` is a last resort, and the caller has to know
|
|
1020
|
+
* which of the two it got.
|
|
939
1021
|
*/
|
|
940
1022
|
function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
941
1023
|
// Check if word has CJK characters — always break at character level
|
|
@@ -943,38 +1025,20 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
|
943
1025
|
// Emoji form their own break opportunities (a run of emoji wraps between
|
|
944
1026
|
// clusters). Only meaningful when a grapheme segmenter is available so ZWJ
|
|
945
1027
|
// sequences / skin-tone / flag pairs stay intact.
|
|
946
|
-
const
|
|
1028
|
+
const segmenter = getGraphemeSegmenter();
|
|
1029
|
+
const hasEmoji = !!segmenter && EMOJI_CANDIDATE.test(word.text) &&
|
|
1030
|
+
graphemes(word.text).some(isEmojiCluster);
|
|
947
1031
|
// Check if word needs break-word splitting — when it won't fit on a fresh line
|
|
948
1032
|
const needsBreak = word.width > contentWidth &&
|
|
949
1033
|
(word.style.overflowWrap === 'break-word' || word.style.wordBreak === 'break-all');
|
|
950
1034
|
if (!hasCJK && !hasEmoji && !needsBreak)
|
|
951
|
-
return [word];
|
|
952
|
-
// overflow-wrap:break-word is
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if (needsBreak && word.style.wordBreak !== 'break-all' &&
|
|
959
|
-
word.style.overflowWrap === 'break-word') {
|
|
960
|
-
const segTexts = word.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/).filter((s) => s.length);
|
|
961
|
-
if (segTexts.length > 1) {
|
|
962
|
-
ctx.font = buildCanvasFont(word.style);
|
|
963
|
-
ctx.letterSpacing = formatLetterSpacing(word.style.letterSpacing);
|
|
964
|
-
const out = [];
|
|
965
|
-
for (const segText of segTexts) {
|
|
966
|
-
const segWidth = cachedMeasureWidth(ctx, segText);
|
|
967
|
-
if (segWidth <= contentWidth) {
|
|
968
|
-
out.push({ ...word, text: segText, width: segWidth });
|
|
969
|
-
}
|
|
970
|
-
else {
|
|
971
|
-
// Segment still overflows — char-break just this segment.
|
|
972
|
-
out.push(...breakWordIfNeeded(ctx, { ...word, text: segText, width: segWidth }, contentWidth, 0));
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return out;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
1035
|
+
return { pieces: [word], emergency: false };
|
|
1036
|
+
// `overflow-wrap: break-word` is the last-resort split; CJK/emoji breaks are
|
|
1037
|
+
// ordinary opportunities that behave nothing like it at the line edge.
|
|
1038
|
+
// `word-break: break-all` genuinely allows a break anywhere, so it is not an
|
|
1039
|
+
// emergency either.
|
|
1040
|
+
const emergency = needsBreak && !hasCJK && !hasEmoji &&
|
|
1041
|
+
word.style.wordBreak !== 'break-all';
|
|
978
1042
|
// Split into characters using cumulative measurement for accuracy.
|
|
979
1043
|
// Measuring each char individually ignores kerning — the sum of individual
|
|
980
1044
|
// widths diverges from the true string width over many characters.
|
|
@@ -988,7 +1052,13 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
|
988
1052
|
const pieces = [];
|
|
989
1053
|
let current = '';
|
|
990
1054
|
let currentWidth = 0;
|
|
1055
|
+
let measuredText = '';
|
|
1056
|
+
let measuredWidth = 0;
|
|
1057
|
+
let currentStartWidth = 0;
|
|
991
1058
|
for (const char of chars) {
|
|
1059
|
+
const nextMeasuredText = measuredText + char;
|
|
1060
|
+
const nextMeasuredWidth = cachedMeasureWidth(ctx, nextMeasuredText);
|
|
1061
|
+
const charWidth = nextMeasuredWidth - measuredWidth;
|
|
992
1062
|
// Emoji clusters each get their own word — a break opportunity between
|
|
993
1063
|
// adjacent emoji, matching the browser line breaker.
|
|
994
1064
|
if (hasEmoji && isEmojiCluster(char)) {
|
|
@@ -997,7 +1067,10 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
|
997
1067
|
current = '';
|
|
998
1068
|
currentWidth = 0;
|
|
999
1069
|
}
|
|
1000
|
-
pieces.push({ ...word, text: char, width:
|
|
1070
|
+
pieces.push({ ...word, text: char, width: charWidth });
|
|
1071
|
+
currentStartWidth = nextMeasuredWidth;
|
|
1072
|
+
measuredText = nextMeasuredText;
|
|
1073
|
+
measuredWidth = nextMeasuredWidth;
|
|
1001
1074
|
continue;
|
|
1002
1075
|
}
|
|
1003
1076
|
// CJK chars always get their own word for wrapping
|
|
@@ -1007,30 +1080,64 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
|
|
|
1007
1080
|
current = '';
|
|
1008
1081
|
currentWidth = 0;
|
|
1009
1082
|
}
|
|
1010
|
-
const charWidth = cachedMeasureWidth(ctx, char);
|
|
1011
1083
|
pieces.push({ ...word, text: char, width: charWidth });
|
|
1084
|
+
currentStartWidth = nextMeasuredWidth;
|
|
1085
|
+
measuredText = nextMeasuredText;
|
|
1086
|
+
measuredWidth = nextMeasuredWidth;
|
|
1012
1087
|
continue;
|
|
1013
1088
|
}
|
|
1014
1089
|
// Use cumulative measurement: measure the growing string, not individual chars
|
|
1015
1090
|
const candidateText = current + char;
|
|
1016
|
-
const candidateWidth =
|
|
1091
|
+
const candidateWidth = nextMeasuredWidth - currentStartWidth;
|
|
1017
1092
|
// For break-word: break when adding this char would exceed container
|
|
1018
1093
|
if (needsBreak && candidateWidth > contentWidth && current) {
|
|
1019
1094
|
pieces.push({ ...word, text: current, width: currentWidth });
|
|
1020
1095
|
current = char;
|
|
1021
|
-
currentWidth =
|
|
1096
|
+
currentWidth = charWidth;
|
|
1097
|
+
currentStartWidth = measuredWidth;
|
|
1098
|
+
measuredText = nextMeasuredText;
|
|
1099
|
+
measuredWidth = nextMeasuredWidth;
|
|
1022
1100
|
continue;
|
|
1023
1101
|
}
|
|
1024
1102
|
current = candidateText;
|
|
1025
1103
|
currentWidth = candidateWidth;
|
|
1104
|
+
measuredText = nextMeasuredText;
|
|
1105
|
+
measuredWidth = nextMeasuredWidth;
|
|
1026
1106
|
}
|
|
1027
1107
|
if (current) {
|
|
1028
1108
|
pieces.push({ ...word, text: current, width: currentWidth });
|
|
1029
1109
|
}
|
|
1030
|
-
return pieces;
|
|
1110
|
+
return { pieces, emergency };
|
|
1031
1111
|
}
|
|
1032
1112
|
/** Punctuation that cannot start a line — stays with the preceding word. */
|
|
1033
|
-
const TRAILING_PUNCT = /^[,.\;:!?\)\]\}'"
|
|
1113
|
+
const TRAILING_PUNCT = /^[,.\;:!?\)\]\}'"»›」』】〕〉》”、。・!),:;?၊-၏។-៖៘-៚]+$/;
|
|
1114
|
+
/** Punctuation that cannot end a line — stays with the following word. */
|
|
1115
|
+
const OPENING_PUNCT = /^[\(\[\{«‹“‘「『【〔〈《(]+$/;
|
|
1116
|
+
/**
|
|
1117
|
+
* Total width of the content directly after `from` that cannot start a line:
|
|
1118
|
+
* trailing punctuation (",.)]}…"), an inline span's right padding/border
|
|
1119
|
+
* (empty boxClose markers), and a word continuation abutting across a run
|
|
1120
|
+
* boundary with no soft-wrap opportunity (`noBreakBefore` — one word split
|
|
1121
|
+
* across two inline spans with different font sizes). The browser includes all
|
|
1122
|
+
* of it when deciding whether the preceding word fits, so the unit wraps
|
|
1123
|
+
* together: if "Music Experie" doesn't leave room for the glued "nce", the
|
|
1124
|
+
* whole word wraps as one. Stops at whitespace or the next breakable word.
|
|
1125
|
+
*/
|
|
1126
|
+
function gluedRunWidth(words, from) {
|
|
1127
|
+
let total = 0;
|
|
1128
|
+
for (let index = from; index < words.length; index++) {
|
|
1129
|
+
const next = words[index];
|
|
1130
|
+
if (next.isSpace || next.text === '\n')
|
|
1131
|
+
break;
|
|
1132
|
+
const isPunctuation = !!next.text && TRAILING_PUNCT.test(next.text);
|
|
1133
|
+
const isClosingEdge = !next.text && !!next.boxClose;
|
|
1134
|
+
const isContinuation = !!next.text && !!next.noBreakBefore;
|
|
1135
|
+
if (!isPunctuation && !isClosingEdge && !isContinuation)
|
|
1136
|
+
break;
|
|
1137
|
+
total += next.width;
|
|
1138
|
+
}
|
|
1139
|
+
return total;
|
|
1140
|
+
}
|
|
1034
1141
|
/**
|
|
1035
1142
|
* Flow words into lines that fit within contentWidth.
|
|
1036
1143
|
* Handles: word wrapping, nowrap, break-word, CJK character wrapping.
|
|
@@ -1106,7 +1213,10 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1106
1213
|
const word = words[wordIndex];
|
|
1107
1214
|
let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
|
|
1108
1215
|
// Inline-block elements expand line height with their vertical padding+margin
|
|
1109
|
-
if (word.
|
|
1216
|
+
if (word.inlineBlockLayout) {
|
|
1217
|
+
wordLineHeight = Math.max(wordLineHeight, word.inlineBlockLayout.marginBoxHeight);
|
|
1218
|
+
}
|
|
1219
|
+
else if (word.boxStyle && word.boxStyle.display === 'inline-block') {
|
|
1110
1220
|
// Clamped at 0: negative margins shrink the margin box, but the original
|
|
1111
1221
|
// `Math.max(h, h + extra)` never let them shrink the LINE, and nothing
|
|
1112
1222
|
// here is measuring a case that says they should.
|
|
@@ -1166,7 +1276,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1166
1276
|
});
|
|
1167
1277
|
const combinedText = cells.map((c) => c.ch).join('');
|
|
1168
1278
|
// Hyphen break opportunities (same rule as the single-word hyphen path).
|
|
1169
|
-
const segTexts = combinedText
|
|
1279
|
+
const segTexts = splitHyphenated(combinedText);
|
|
1170
1280
|
const hyphenMode = segTexts.length > 1;
|
|
1171
1281
|
const fitsLine = currentLine.totalWidth + combined <= effWidth();
|
|
1172
1282
|
// A hyphen is an ordinary break opportunity — intervene whenever the
|
|
@@ -1268,36 +1378,36 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1268
1378
|
}
|
|
1269
1379
|
}
|
|
1270
1380
|
// Break long words / CJK characters if needed
|
|
1271
|
-
const
|
|
1381
|
+
const broken = (!word.isSpace && word.text.length > 1)
|
|
1272
1382
|
? breakWordIfNeeded(ctx, word, effWidth(), currentLine.totalWidth)
|
|
1273
|
-
: [word];
|
|
1274
|
-
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1383
|
+
: { pieces: [word], emergency: false };
|
|
1384
|
+
const pieces = broken.pieces;
|
|
1385
|
+
// An emergency break is one taken inside a word that cannot fit on a fresh
|
|
1386
|
+
// line. Native layout first takes the ordinary whitespace opportunity
|
|
1387
|
+
// before that word; it does not pack the first emergency fragment into
|
|
1388
|
+
// space left by the preceding word. Every other kind of split — CJK,
|
|
1389
|
+
// emoji, hyphens, break-all — is a normal opportunity and fills first.
|
|
1390
|
+
if (broken.emergency && currentLine.words.some((lineWord) => !lineWord.isSpace)) {
|
|
1391
|
+
pushLine(true);
|
|
1392
|
+
afterHardBreak = false;
|
|
1393
|
+
}
|
|
1394
|
+
const gluedTailWidth = gluedRunWidth(words, wordIndex + 1);
|
|
1395
|
+
for (let pieceIndex = 0; pieceIndex < pieces.length; pieceIndex++) {
|
|
1396
|
+
const piece = pieces[pieceIndex];
|
|
1397
|
+
const isLastPiece = pieceIndex === pieces.length - 1;
|
|
1398
|
+
// A trailing-punctuation piece produced by character splitting still
|
|
1399
|
+
// belongs to the preceding character. Include it before deciding
|
|
1400
|
+
// whether that character fits; appending it afterward can overflow the
|
|
1401
|
+
// line (`…습니다.` must wrap as `다.`, never leave a hanging period).
|
|
1402
|
+
let tail = isLastPiece ? gluedTailWidth : 0;
|
|
1403
|
+
for (let tailIndex = pieceIndex + 1; tailIndex < pieces.length; tailIndex++) {
|
|
1404
|
+
const trailing = pieces[tailIndex];
|
|
1405
|
+
if (!trailing.text || !TRAILING_PUNCT.test(trailing.text))
|
|
1406
|
+
break;
|
|
1407
|
+
tail += trailing.width;
|
|
1408
|
+
if (tailIndex === pieces.length - 1)
|
|
1409
|
+
tail += gluedTailWidth;
|
|
1294
1410
|
}
|
|
1295
|
-
break;
|
|
1296
|
-
}
|
|
1297
|
-
for (const piece of pieces) {
|
|
1298
|
-
const isLastPiece = piece === pieces[pieces.length - 1];
|
|
1299
|
-
// Only the last piece of the word carries the glued tail.
|
|
1300
|
-
const tail = isLastPiece ? gluedTailWidth : 0;
|
|
1301
1411
|
// Trailing punctuation (e.g. comma after </span>) should not wrap
|
|
1302
1412
|
// independently — browsers keep it with the preceding word.
|
|
1303
1413
|
const isTrailingPunct = !piece.isSpace && piece.text.length > 0 &&
|
|
@@ -1308,26 +1418,52 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1308
1418
|
// opportunity before it — keep it with the preceding word like trailing
|
|
1309
1419
|
// punctuation. Only the FIRST piece carries the flag; a break-word split
|
|
1310
1420
|
// inside the word may still wrap mid-word.
|
|
1311
|
-
const isGlued =
|
|
1312
|
-
currentLine.words.length
|
|
1313
|
-
|
|
1421
|
+
const isGlued = currentLine.words.length > 0 &&
|
|
1422
|
+
!currentLine.words[currentLine.words.length - 1].isSpace &&
|
|
1423
|
+
((piece === pieces[0] && piece.noBreakBefore) ||
|
|
1424
|
+
(!piece.text && !!piece.boxClose));
|
|
1314
1425
|
// Leading inline padding/border (an empty boxOpen marker) must not be
|
|
1315
1426
|
// stranded at the end of a line — it belongs with the span's following
|
|
1316
1427
|
// content (CSS applies padding-left at the box's start). Include the next
|
|
1317
1428
|
// content word's width in this marker's fit test so the two wrap together
|
|
1318
1429
|
// and the left padding lands on the new line with the content.
|
|
1319
1430
|
let headExtra = 0;
|
|
1320
|
-
|
|
1321
|
-
|
|
1431
|
+
const isOpener = OPENING_PUNCT.test(piece.text);
|
|
1432
|
+
if (isOpener && !isLastPiece) {
|
|
1433
|
+
// An opener stranded mid-word by the per-character CJK split glues to
|
|
1434
|
+
// its NEXT PIECE, not the next word: Chrome never ends a line with
|
|
1435
|
+
// \u300C or \uFF08 (measured: \u6C34x5 + opener + \u6C34x7 at width
|
|
1436
|
+
// 100 — the DOM wraps the opener down with its following character).
|
|
1437
|
+
// The word-level branch below reads words[wordIndex + 1] and finds
|
|
1438
|
+
// nothing mid-word, which left the bracket dangling at end of line.
|
|
1439
|
+
headExtra = pieces[pieceIndex + 1].width;
|
|
1440
|
+
}
|
|
1441
|
+
else if ((!piece.text && piece.boxOpen) ||
|
|
1442
|
+
(isOpener && gluedTailWidth === 0)) {
|
|
1443
|
+
let nextIndex = wordIndex + 1;
|
|
1444
|
+
// Opening punctuation can be followed by an inline box edge before
|
|
1445
|
+
// its first glyph: `(<span>word</span>)`. Keep both the edge and that
|
|
1446
|
+
// first breakable glyph on the same line as the punctuation.
|
|
1447
|
+
while (nextIndex < words.length && !words[nextIndex].text && words[nextIndex].boxOpen) {
|
|
1448
|
+
headExtra += words[nextIndex].width;
|
|
1449
|
+
nextIndex++;
|
|
1450
|
+
}
|
|
1451
|
+
const next = words[nextIndex];
|
|
1322
1452
|
if (next && !next.isSpace && next.text) {
|
|
1323
1453
|
// Only the next word's first BREAKABLE unit must stay with the leading
|
|
1324
1454
|
// padding — the whole word for unbreakable Latin, but just the first
|
|
1325
1455
|
// character for CJK / break-word (which wrap per character). Using the
|
|
1326
1456
|
// whole word here would over-wrap a long CJK run that follows padding.
|
|
1327
1457
|
const np = next.text.length > 1
|
|
1328
|
-
? breakWordIfNeeded(ctx, next, effWidth(), 0)
|
|
1458
|
+
? breakWordIfNeeded(ctx, next, effWidth(), 0).pieces
|
|
1329
1459
|
: [next];
|
|
1330
|
-
headExtra
|
|
1460
|
+
headExtra += np[0].width;
|
|
1461
|
+
if (np.length === 1) {
|
|
1462
|
+
// The first word's own inseparable tail is part of the same unit:
|
|
1463
|
+
// `(<span>p50</span>,` may break before `(` or after the comma,
|
|
1464
|
+
// never between the word, closing edge, and comma.
|
|
1465
|
+
headExtra += gluedRunWidth(words, nextIndex + 1);
|
|
1466
|
+
}
|
|
1331
1467
|
}
|
|
1332
1468
|
}
|
|
1333
1469
|
// A soft-hyphen break point draws a visible '-' when the line breaks
|
|
@@ -1341,17 +1477,28 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1341
1477
|
ctx.letterSpacing = formatLetterSpacing(piece.style.letterSpacing);
|
|
1342
1478
|
shReserve = cachedMeasureWidth(ctx, '-');
|
|
1343
1479
|
}
|
|
1480
|
+
const candidateLineWidth = currentLine.totalWidth + piece.width +
|
|
1481
|
+
shReserve + tail + headExtra;
|
|
1344
1482
|
// Would this piece overflow?
|
|
1345
1483
|
if (!piece.isSpace && !isTrailingPunct && !isGlued && currentLine.words.length > 0 &&
|
|
1346
|
-
|
|
1347
|
-
const overflow =
|
|
1484
|
+
candidateLineWidth > effWidth()) {
|
|
1485
|
+
const overflow = candidateLineWidth - effWidth();
|
|
1348
1486
|
// For borderline cases (overflow < 1px), word-by-word delta
|
|
1349
1487
|
// accumulation may introduce rounding errors. Re-measure the
|
|
1350
1488
|
// full candidate line as a single string for accuracy.
|
|
1351
1489
|
// Only works for single-font lines — mixed fonts can't be
|
|
1352
1490
|
// measured as one string.
|
|
1353
1491
|
let reallyOverflows = true;
|
|
1354
|
-
|
|
1492
|
+
// A preserved tab's advance is position-dependent (tab stops), but
|
|
1493
|
+
// measureText('\t') reports a flat control advance — the one-string
|
|
1494
|
+
// re-measure would under-count the line by most of a tab stop and
|
|
1495
|
+
// falsely keep the overflowing word. Cumulative widths already carry
|
|
1496
|
+
// the true tab advance, so trust them on tab lines. (The piece itself
|
|
1497
|
+
// is never a tab here: tab words are spaces, and this branch requires
|
|
1498
|
+
// a non-space piece.)
|
|
1499
|
+
if (overflow < 1 &&
|
|
1500
|
+
!currentLine.words.some((lineWord) => lineWord.isTab) &&
|
|
1501
|
+
!hasMixedTextMetrics([...currentLine.words, piece])) {
|
|
1355
1502
|
applyFont(ctx, piece.style);
|
|
1356
1503
|
const fullText = currentLine.words.map(w => w.text).join('') + piece.text +
|
|
1357
1504
|
(piece.isSoftHyphenBreak ? '-' : '');
|
|
@@ -1359,54 +1506,24 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1359
1506
|
// markers, inline-block margins) that measureText(fullText) misses —
|
|
1360
1507
|
// add them back so padded inline spans aren't under-measured.
|
|
1361
1508
|
let markerWidth = 0;
|
|
1362
|
-
for (const w of currentLine.words)
|
|
1509
|
+
for (const w of currentLine.words) {
|
|
1363
1510
|
if (!w.text)
|
|
1364
1511
|
markerWidth += w.width;
|
|
1512
|
+
else if (w.isSpace)
|
|
1513
|
+
markerWidth += w.style.wordSpacing;
|
|
1514
|
+
}
|
|
1365
1515
|
if (!piece.text)
|
|
1366
1516
|
markerWidth += piece.width;
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
//
|
|
1517
|
+
else if (piece.isSpace)
|
|
1518
|
+
markerWidth += piece.style.wordSpacing;
|
|
1519
|
+
const fullWidth = cachedMeasureWidth(ctx, fullText) + markerWidth + tail +
|
|
1520
|
+
headExtra;
|
|
1521
|
+
// Allow only a hair of sub-pixel overflow. A broader tolerance fixes
|
|
1522
|
+
// isolated knife-edges but packs extra words in ordinary paragraphs.
|
|
1372
1523
|
if (fullWidth <= effWidth() + 0.02) {
|
|
1373
1524
|
reallyOverflows = false;
|
|
1374
1525
|
}
|
|
1375
1526
|
}
|
|
1376
|
-
// Hyphen break on current line: before wrapping the whole word,
|
|
1377
|
-
// try fitting a hyphen prefix on the current line. Browsers prefer
|
|
1378
|
-
// keeping content on the current line by splitting at hyphens.
|
|
1379
|
-
if (reallyOverflows && piece.text.includes('-')) {
|
|
1380
|
-
const parts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
1381
|
-
if (parts.length > 1) {
|
|
1382
|
-
applyFont(ctx, piece.style);
|
|
1383
|
-
let fitted = '';
|
|
1384
|
-
let fittedWidth = 0;
|
|
1385
|
-
let partIdx = 0;
|
|
1386
|
-
const available = effWidth() - currentLine.totalWidth;
|
|
1387
|
-
for (; partIdx < parts.length; partIdx++) {
|
|
1388
|
-
const candidate = fitted + parts[partIdx];
|
|
1389
|
-
const candidateWidth = cachedMeasureWidth(ctx, candidate);
|
|
1390
|
-
if (candidateWidth > available)
|
|
1391
|
-
break;
|
|
1392
|
-
fitted = candidate;
|
|
1393
|
-
fittedWidth = candidateWidth;
|
|
1394
|
-
}
|
|
1395
|
-
if (partIdx > 0 && partIdx < parts.length) {
|
|
1396
|
-
currentLine.words.push({ ...piece, text: fitted, width: fittedWidth });
|
|
1397
|
-
currentLine.totalWidth += fittedWidth;
|
|
1398
|
-
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
1399
|
-
pushLine(true);
|
|
1400
|
-
afterHardBreak = false;
|
|
1401
|
-
const remainder = parts.slice(partIdx).join('');
|
|
1402
|
-
const remainderWidth = cachedMeasureWidth(ctx, remainder);
|
|
1403
|
-
currentLine.words.push({ ...piece, text: remainder, width: remainderWidth });
|
|
1404
|
-
currentLine.totalWidth += remainderWidth;
|
|
1405
|
-
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
1406
|
-
continue;
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
1527
|
if (reallyOverflows) {
|
|
1411
1528
|
if (_debug) {
|
|
1412
1529
|
const lineText = currentLine.words.map(w => w.text).join('');
|
|
@@ -1440,48 +1557,6 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1440
1557
|
pieceWidth = advance;
|
|
1441
1558
|
piece.width = pieceWidth;
|
|
1442
1559
|
}
|
|
1443
|
-
// Hyphen break on a fresh line when word still too wide.
|
|
1444
|
-
if (currentLine.words.length === 0 && pieceWidth > effWidth() &&
|
|
1445
|
-
!piece.isSpace && piece.text.includes('-')) {
|
|
1446
|
-
const subParts = piece.text.split(/(?<=-)(?!\d)|(?<=[^\d]-)/);
|
|
1447
|
-
if (subParts.length > 1) {
|
|
1448
|
-
applyFont(ctx, piece.style);
|
|
1449
|
-
// Inject sub-parts as individual pieces — they'll flow through
|
|
1450
|
-
// the normal overflow/wrap logic on subsequent iterations.
|
|
1451
|
-
const newPieces = subParts.filter(p => p).map(p => ({
|
|
1452
|
-
...piece,
|
|
1453
|
-
text: p,
|
|
1454
|
-
width: cachedMeasureWidth(ctx, p),
|
|
1455
|
-
}));
|
|
1456
|
-
// Replace current piece with the sub-parts by splicing into the pieces array
|
|
1457
|
-
// Since we're iterating `pieces`, we push remaining sub-parts after the first
|
|
1458
|
-
// onto the current line normally, letting the overflow check handle wrapping.
|
|
1459
|
-
let first = true;
|
|
1460
|
-
for (const sp of newPieces) {
|
|
1461
|
-
if (first) {
|
|
1462
|
-
first = false;
|
|
1463
|
-
// First sub-part: add to current line (it fits since it's smaller)
|
|
1464
|
-
currentLine.words.push(sp);
|
|
1465
|
-
currentLine.totalWidth += sp.width;
|
|
1466
|
-
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
1467
|
-
}
|
|
1468
|
-
else if (currentLine.totalWidth + sp.width > effWidth()) {
|
|
1469
|
-
// Overflow: wrap to next line
|
|
1470
|
-
pushLine(true);
|
|
1471
|
-
afterHardBreak = false;
|
|
1472
|
-
currentLine.words.push(sp);
|
|
1473
|
-
currentLine.totalWidth += sp.width;
|
|
1474
|
-
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
1475
|
-
}
|
|
1476
|
-
else {
|
|
1477
|
-
currentLine.words.push(sp);
|
|
1478
|
-
currentLine.totalWidth += sp.width;
|
|
1479
|
-
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
continue;
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
1560
|
currentLine.words.push(piece);
|
|
1486
1561
|
currentLine.totalWidth += pieceWidth;
|
|
1487
1562
|
currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
|
|
@@ -1492,12 +1567,51 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1492
1567
|
pushLine();
|
|
1493
1568
|
return lines;
|
|
1494
1569
|
}
|
|
1570
|
+
function prepareInlineBlocks(ctx, words, containingWidth, useBulletProbe) {
|
|
1571
|
+
for (const word of words) {
|
|
1572
|
+
const source = word.inlineBlock;
|
|
1573
|
+
if (!source)
|
|
1574
|
+
continue;
|
|
1575
|
+
const s = source.style;
|
|
1576
|
+
const margins = horizontalMargins(s);
|
|
1577
|
+
const frame = horizontalFrame(s);
|
|
1578
|
+
const preferredContent = Math.max(0, word.width - margins - frame);
|
|
1579
|
+
// The source node IS the inner root: the inline formatting context reads
|
|
1580
|
+
// only font, whiteSpace, direction, text-align/indent and line-clamp off
|
|
1581
|
+
// it. Its box properties are applied here, by the caller, so there is
|
|
1582
|
+
// nothing to zero out first.
|
|
1583
|
+
const availableContent = Math.max(0, containingWidth - margins - frame);
|
|
1584
|
+
let contentWidth = s.width > 0
|
|
1585
|
+
? Math.max(0, s.width - frame)
|
|
1586
|
+
: Math.min(preferredContent, Math.max(minimumInlineContentWidth(ctx, source), availableContent));
|
|
1587
|
+
if (s.minWidth !== null) {
|
|
1588
|
+
contentWidth = Math.max(contentWidth, Math.max(0, s.minWidth - frame));
|
|
1589
|
+
}
|
|
1590
|
+
const inner = layoutInlineContent(ctx, source, 0, 0, contentWidth, useBulletProbe);
|
|
1591
|
+
const contentHeight = inner.height || getLineHeight(ctx, s, useBulletProbe);
|
|
1592
|
+
const lastBaseline = inner.lines.at(-1)?.y ??
|
|
1593
|
+
leadedBox(ctx, s, useBulletProbe).ascent;
|
|
1594
|
+
const extra = inlineBlockExtra(s);
|
|
1595
|
+
const baselineOffset = extra.top + lastBaseline;
|
|
1596
|
+
const marginBoxHeight = extra.top + contentHeight + extra.bottom;
|
|
1597
|
+
word.width = margins + frame + contentWidth;
|
|
1598
|
+
word.inlineBlockLayout = {
|
|
1599
|
+
nodes: inner.nodes,
|
|
1600
|
+
lines: inner.lines,
|
|
1601
|
+
contentWidth,
|
|
1602
|
+
contentHeight,
|
|
1603
|
+
baselineOffset,
|
|
1604
|
+
marginBoxHeight,
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1495
1608
|
/**
|
|
1496
1609
|
* Layout inline content: text wrapping + positioning using pure canvas measurement.
|
|
1497
1610
|
* Returns layout nodes and the total height consumed.
|
|
1498
1611
|
*/
|
|
1499
1612
|
function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = false, clamp) {
|
|
1500
1613
|
const results = [];
|
|
1614
|
+
const emittedLines = [];
|
|
1501
1615
|
// Text nodes covered by an inline element declaring background-clip:text
|
|
1502
1616
|
// (clipRuns) or --rt-text-stroke-image (strokeImageRuns), mapped to that
|
|
1503
1617
|
// declaring element's style. A post-pass turns each per-line run of
|
|
@@ -1507,26 +1621,15 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1507
1621
|
if (clamp && (clamp.exhausted || clamp.remaining <= 0)) {
|
|
1508
1622
|
// An ancestor's clamp already used its line budget — drop this content.
|
|
1509
1623
|
clamp.exhausted = true;
|
|
1510
|
-
return { nodes: results, height: 0 };
|
|
1624
|
+
return { nodes: results, height: 0, lines: emittedLines };
|
|
1511
1625
|
}
|
|
1512
1626
|
const runs = collectTextRuns(node);
|
|
1513
1627
|
if (runs.length === 0)
|
|
1514
|
-
return { nodes: results, height: 0 };
|
|
1628
|
+
return { nodes: results, height: 0, lines: emittedLines };
|
|
1515
1629
|
const words = tokenizeRuns(ctx, runs);
|
|
1630
|
+
prepareInlineBlocks(ctx, words, contentWidth, useBulletProbe);
|
|
1516
1631
|
const textIndent = node.style.textIndent || 0;
|
|
1517
|
-
|
|
1518
|
-
// Chrome sizes the interval as tab-size(8) × the block font's space advance
|
|
1519
|
-
// plus letter- and word-spacing (css-text-3 §tab-size) — verified against
|
|
1520
|
-
// the DOM: a tab inside a bold span still uses the regular-weight space.
|
|
1521
|
-
applyFont(ctx, node.style);
|
|
1522
|
-
const prevLetterSpacing = ctx.letterSpacing;
|
|
1523
|
-
ctx.letterSpacing = '0px';
|
|
1524
|
-
const blockSpaceWidth = cachedMeasureWidth(ctx, ' ');
|
|
1525
|
-
ctx.letterSpacing = prevLetterSpacing;
|
|
1526
|
-
const tabMetrics = {
|
|
1527
|
-
interval: (blockSpaceWidth + (node.style.letterSpacing || 0) + (node.style.wordSpacing || 0)) * 8,
|
|
1528
|
-
halfSpace: blockSpaceWidth / 2,
|
|
1529
|
-
};
|
|
1632
|
+
const tabMetrics = tabStopMetrics(ctx, node.style);
|
|
1530
1633
|
// The block's own font + line-height set the strut: the minimum height of
|
|
1531
1634
|
// every line box, even a line holding only smaller inline content.
|
|
1532
1635
|
const strutLineHeight = getLineHeight(ctx, node.style, useBulletProbe);
|
|
@@ -1675,7 +1778,12 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1675
1778
|
// baseline and does not honour vertical-align on it, so shifting the box
|
|
1676
1779
|
// here would grow the line one way while the paint went the other.
|
|
1677
1780
|
const atomic = word.boxStyle?.display === 'inline-block' ? word.boxStyle : null;
|
|
1678
|
-
if (
|
|
1781
|
+
if (word.inlineBlockLayout) {
|
|
1782
|
+
const ib = word.inlineBlockLayout;
|
|
1783
|
+
box.ascent = ib.baselineOffset;
|
|
1784
|
+
box.descent = ib.marginBoxHeight - ib.baselineOffset;
|
|
1785
|
+
}
|
|
1786
|
+
else if (atomic) {
|
|
1679
1787
|
const extra = inlineBlockExtra(atomic);
|
|
1680
1788
|
box.ascent += extra.top;
|
|
1681
1789
|
box.descent += extra.bottom;
|
|
@@ -1686,7 +1794,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1686
1794
|
const lineBaselineY = curY + lineAscent;
|
|
1687
1795
|
// Emit inline background box using line-level baseline for vertical alignment.
|
|
1688
1796
|
// Uses the line's ascent/descent (not the box's own font) so box aligns with text.
|
|
1689
|
-
const emitInlineBox = (style, bx, bw) => {
|
|
1797
|
+
const emitInlineBox = (style, bx, bw, textWord) => {
|
|
1690
1798
|
// The box's OWN font decides its height, not the line's largest. An
|
|
1691
1799
|
// inline-block's content box is its LINE-HEIGHT, though, not the bare
|
|
1692
1800
|
// font metrics — measured against Chrome, bare metrics put it at
|
|
@@ -1702,7 +1810,20 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1702
1810
|
// TOP instead detached from its own glyphs as soon as something taller
|
|
1703
1811
|
// shared the line — measured, a background at y 4..33 around text whose
|
|
1704
1812
|
// baseline was 46.
|
|
1705
|
-
|
|
1813
|
+
let baselineY = lineBaselineY;
|
|
1814
|
+
// A vertical-align that moves the glyphs moves their band with them: the
|
|
1815
|
+
// shift comes from the SAME call, on the SAME word, as the text emit
|
|
1816
|
+
// below, so box and glyphs cannot drift apart. Computed independently
|
|
1817
|
+
// they did — the band painted at the unshifted baseline under super/
|
|
1818
|
+
// sub'd text. Inline-block stays put: the emit pass does not honour
|
|
1819
|
+
// vertical-align on it (see the line-box union above).
|
|
1820
|
+
if (textWord && style.display !== 'inline-block') {
|
|
1821
|
+
const va = textWord.style.verticalAlign;
|
|
1822
|
+
if (isShiftedVAlign(va)) {
|
|
1823
|
+
baselineY += verticalAlignShift(va, ctx, textWord.style, textWord.parentStyle ?? blockStyle, useBulletProbe);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const boxY = baselineY - boxAscent - padTop;
|
|
1706
1827
|
results.push({
|
|
1707
1828
|
type: 'box', style, x: bx, y: boxY, width: bw, height: boxHeight,
|
|
1708
1829
|
tagName: 'span', children: [],
|
|
@@ -1713,39 +1834,56 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1713
1834
|
let scanX = curX;
|
|
1714
1835
|
let boxStartX = scanX;
|
|
1715
1836
|
let currentBoxStyle;
|
|
1716
|
-
|
|
1837
|
+
// First text word of the open box group — its presence decides whether
|
|
1838
|
+
// the group's band is emitted at all, and its style pair decides where
|
|
1839
|
+
// the band's baseline sits (the same pair the text emit shifts by).
|
|
1840
|
+
let boxTextWord;
|
|
1717
1841
|
for (const word of line.words) {
|
|
1718
1842
|
if (word.boxOpen && word.boxClose && word.text) {
|
|
1719
1843
|
if (currentBoxStyle) {
|
|
1720
|
-
if (
|
|
1721
|
-
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
|
|
1844
|
+
if (boxTextWord)
|
|
1845
|
+
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX, boxTextWord);
|
|
1722
1846
|
currentBoxStyle = undefined;
|
|
1723
|
-
|
|
1847
|
+
boxTextWord = undefined;
|
|
1724
1848
|
}
|
|
1725
1849
|
const s = word.style;
|
|
1726
|
-
const textWidth = word.width - s.marginLeft - s.borderLeftWidth - s.paddingLeft
|
|
1727
|
-
- s.paddingRight - s.borderRightWidth - s.marginRight;
|
|
1728
1850
|
const boxX = scanX + s.marginLeft;
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1851
|
+
if (word.inlineBlockLayout) {
|
|
1852
|
+
const ib = word.inlineBlockLayout;
|
|
1853
|
+
results.push({
|
|
1854
|
+
type: 'box', style: s, x: boxX,
|
|
1855
|
+
y: lineBaselineY - ib.baselineOffset + s.marginTop,
|
|
1856
|
+
width: s.borderLeftWidth + s.paddingLeft + ib.contentWidth +
|
|
1857
|
+
s.paddingRight + s.borderRightWidth,
|
|
1858
|
+
height: s.borderTopWidth + s.paddingTop + ib.contentHeight +
|
|
1859
|
+
s.paddingBottom + s.borderBottomWidth,
|
|
1860
|
+
tagName: 'span', children: [],
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
const textWidth = word.width - horizontalMargins(s) - horizontalFrame(s);
|
|
1865
|
+
const boxW = s.borderLeftWidth + s.paddingLeft + textWidth +
|
|
1866
|
+
s.paddingRight + s.borderRightWidth;
|
|
1867
|
+
emitInlineBox(s, boxX, boxW, word);
|
|
1868
|
+
}
|
|
1869
|
+
boxTextWord = undefined;
|
|
1732
1870
|
scanX += word.width;
|
|
1733
1871
|
continue;
|
|
1734
1872
|
}
|
|
1735
1873
|
if (word.boxStyle !== currentBoxStyle) {
|
|
1736
|
-
if (currentBoxStyle &&
|
|
1737
|
-
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
|
|
1874
|
+
if (currentBoxStyle && boxTextWord) {
|
|
1875
|
+
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX, boxTextWord);
|
|
1738
1876
|
}
|
|
1739
1877
|
currentBoxStyle = word.boxStyle;
|
|
1740
1878
|
boxStartX = scanX;
|
|
1741
|
-
|
|
1879
|
+
boxTextWord = undefined;
|
|
1742
1880
|
}
|
|
1743
1881
|
if (word.text && !word.isSpace)
|
|
1744
|
-
|
|
1882
|
+
boxTextWord ?? (boxTextWord = word);
|
|
1745
1883
|
scanX += word.width + (word.isSpace ? justifyExtraPerSpace : 0);
|
|
1746
1884
|
}
|
|
1747
|
-
if (currentBoxStyle &&
|
|
1748
|
-
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
|
|
1885
|
+
if (currentBoxStyle && boxTextWord) {
|
|
1886
|
+
emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX, boxTextWord);
|
|
1749
1887
|
}
|
|
1750
1888
|
}
|
|
1751
1889
|
// Emit text nodes.
|
|
@@ -1873,6 +2011,50 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1873
2011
|
if (word.boxOpen && word.boxClose) {
|
|
1874
2012
|
const s = word.style;
|
|
1875
2013
|
const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
|
|
2014
|
+
if (word.inlineBlockLayout) {
|
|
2015
|
+
const ib = word.inlineBlockLayout;
|
|
2016
|
+
const contentY = lineBaselineY - ib.baselineOffset + s.marginTop +
|
|
2017
|
+
s.borderTopWidth + s.paddingTop;
|
|
2018
|
+
const move = (layoutNode) => {
|
|
2019
|
+
layoutNode.x += textX;
|
|
2020
|
+
layoutNode.y += contentY;
|
|
2021
|
+
if (layoutNode.type === 'text') {
|
|
2022
|
+
if (layoutNode.lineBaselineY !== undefined)
|
|
2023
|
+
layoutNode.lineBaselineY += contentY;
|
|
2024
|
+
if (layoutNode.clip) {
|
|
2025
|
+
layoutNode.clip.x += textX;
|
|
2026
|
+
layoutNode.clip.y += contentY;
|
|
2027
|
+
}
|
|
2028
|
+
if (layoutNode.strokeImage) {
|
|
2029
|
+
layoutNode.strokeImage.x += textX;
|
|
2030
|
+
layoutNode.strokeImage.y += contentY;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
else {
|
|
2034
|
+
for (const child of layoutNode.children)
|
|
2035
|
+
move(child);
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
for (const innerNode of ib.nodes) {
|
|
2039
|
+
move(innerNode);
|
|
2040
|
+
results.push(innerNode);
|
|
2041
|
+
}
|
|
2042
|
+
for (const innerLine of ib.lines.slice(0, -1)) {
|
|
2043
|
+
const translated = {
|
|
2044
|
+
y: Math.round(innerLine.y + contentY),
|
|
2045
|
+
text: innerLine.text,
|
|
2046
|
+
bounds: {
|
|
2047
|
+
x: innerLine.bounds.x + textX,
|
|
2048
|
+
y: innerLine.bounds.y + contentY,
|
|
2049
|
+
width: innerLine.bounds.width,
|
|
2050
|
+
height: innerLine.bounds.height,
|
|
2051
|
+
},
|
|
2052
|
+
};
|
|
2053
|
+
emittedLines.push(translated);
|
|
2054
|
+
}
|
|
2055
|
+
curX += word.width;
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
1876
2058
|
const node = {
|
|
1877
2059
|
type: 'text',
|
|
1878
2060
|
text: word.text,
|
|
@@ -1922,9 +2104,12 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1922
2104
|
const lineWidth = align === 'justify' && justifyExtraPerSpace > 0
|
|
1923
2105
|
? lineMaxWidth
|
|
1924
2106
|
: line.totalWidth;
|
|
1925
|
-
|
|
2107
|
+
const emittedLine = {
|
|
1926
2108
|
y: Math.round(lineBaselineY),
|
|
1927
|
-
|
|
2109
|
+
// An inline-block's earlier rows were emitted as their own lines above,
|
|
2110
|
+
// so this line carries only its LAST row — every glyph appears once,
|
|
2111
|
+
// in order.
|
|
2112
|
+
text: line.words.map((word) => word.inlineBlockLayout?.lines.at(-1)?.text ?? word.text).join(''),
|
|
1928
2113
|
bounds: {
|
|
1929
2114
|
x: lineLeftX,
|
|
1930
2115
|
// The line box starts at curY — this is the CSS line box, which
|
|
@@ -1935,7 +2120,8 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1935
2120
|
width: lineWidth,
|
|
1936
2121
|
height: lineBoxHeight,
|
|
1937
2122
|
},
|
|
1938
|
-
}
|
|
2123
|
+
};
|
|
2124
|
+
emittedLines.push(emittedLine);
|
|
1939
2125
|
curY += lineBoxHeight;
|
|
1940
2126
|
}
|
|
1941
2127
|
assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
|
|
@@ -1948,7 +2134,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1948
2134
|
assignInlineFragmentBoxes(ctx, results, strokeImageRuns, (node, s, box) => {
|
|
1949
2135
|
node.strokeImage = { image: s.webkitTextStrokeImage, ...box };
|
|
1950
2136
|
});
|
|
1951
|
-
return { nodes: results, height: curY - y };
|
|
2137
|
+
return { nodes: results, height: curY - y, lines: emittedLines };
|
|
1952
2138
|
}
|
|
1953
2139
|
/**
|
|
1954
2140
|
* Give each text run covered by an inline paint declarer (background-clip:text
|
|
@@ -2028,6 +2214,23 @@ function isBlock(node) {
|
|
|
2028
2214
|
d === 'table-row' || d === 'table-cell' || d === 'table-row-group' ||
|
|
2029
2215
|
d === 'table-header-group' || d === 'table-footer-group';
|
|
2030
2216
|
}
|
|
2217
|
+
function allowsMarginCollapseThrough(node) {
|
|
2218
|
+
const display = node.style.display;
|
|
2219
|
+
return (display === 'block' || display === 'list-item') &&
|
|
2220
|
+
(node.tagName === 'li' || node.tagName === 'ul' || node.tagName === 'ol' ||
|
|
2221
|
+
node.tagName === 'dd' || node.tagName === 'dt');
|
|
2222
|
+
}
|
|
2223
|
+
function collapsibleMarginTop(node) {
|
|
2224
|
+
const marginTop = node.style.marginTop;
|
|
2225
|
+
if (!allowsMarginCollapseThrough(node) || node.style.paddingTop !== 0 ||
|
|
2226
|
+
node.style.borderTopWidth !== 0) {
|
|
2227
|
+
return marginTop;
|
|
2228
|
+
}
|
|
2229
|
+
const firstChild = node.children[0];
|
|
2230
|
+
return firstChild && isBlock(firstChild)
|
|
2231
|
+
? collapseMargins(marginTop, collapsibleMarginTop(firstChild))
|
|
2232
|
+
: marginTop;
|
|
2233
|
+
}
|
|
2031
2234
|
/**
|
|
2032
2235
|
* Layout a block-level element and all its children.
|
|
2033
2236
|
* Returns the LayoutBox and total height consumed (including margins).
|
|
@@ -2098,7 +2301,8 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2098
2301
|
if (hasOnlyInlineChildren(node)) {
|
|
2099
2302
|
// Inline formatting context
|
|
2100
2303
|
const bulletProbe = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
|
|
2101
|
-
const { nodes, height } = layoutInlineContent(ctx, node, contentX, contentStartY, contentWidth, bulletProbe, clamp);
|
|
2304
|
+
const { nodes, height, lines } = layoutInlineContent(ctx, node, contentX, contentStartY, contentWidth, bulletProbe, clamp);
|
|
2305
|
+
_lines.push(...lines);
|
|
2102
2306
|
box.children = nodes;
|
|
2103
2307
|
box.height = borderTop + padTop + height + padBottom + borderBottom;
|
|
2104
2308
|
}
|
|
@@ -2108,8 +2312,7 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2108
2312
|
let prevMarginBottom = 0;
|
|
2109
2313
|
let hasContent = false; // tracks whether we've placed any content
|
|
2110
2314
|
// Margin collapsing through parent: only for list elements.
|
|
2111
|
-
const allowCollapseThrough = node
|
|
2112
|
-
node.tagName === 'dd' || node.tagName === 'dt';
|
|
2315
|
+
const allowCollapseThrough = allowsMarginCollapseThrough(node);
|
|
2113
2316
|
for (let ci = 0; ci < node.children.length; ci++) {
|
|
2114
2317
|
const child = node.children[ci];
|
|
2115
2318
|
// Line-clamp budget exhausted — everything below the cut is dropped,
|
|
@@ -2133,10 +2336,8 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2133
2336
|
}
|
|
2134
2337
|
}
|
|
2135
2338
|
// Apply pending margin before inline content
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
prevMarginBottom = 0;
|
|
2139
|
-
}
|
|
2339
|
+
curY += prevMarginBottom;
|
|
2340
|
+
prevMarginBottom = 0;
|
|
2140
2341
|
const inlineGroup = {
|
|
2141
2342
|
element: null,
|
|
2142
2343
|
tagName: 'div',
|
|
@@ -2145,7 +2346,8 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2145
2346
|
textContent: null,
|
|
2146
2347
|
};
|
|
2147
2348
|
const bulletProbe2 = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
|
|
2148
|
-
const { nodes, height } = layoutInlineContent(ctx, inlineGroup, contentX, curY, contentWidth, bulletProbe2, clamp);
|
|
2349
|
+
const { nodes, height, lines } = layoutInlineContent(ctx, inlineGroup, contentX, curY, contentWidth, bulletProbe2, clamp);
|
|
2350
|
+
_lines.push(...lines);
|
|
2149
2351
|
box.children.push(...nodes);
|
|
2150
2352
|
curY += height;
|
|
2151
2353
|
prevMarginBottom = 0;
|
|
@@ -2153,9 +2355,7 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2153
2355
|
continue;
|
|
2154
2356
|
}
|
|
2155
2357
|
// Block child — collapse margins
|
|
2156
|
-
const childMarginTop = child
|
|
2157
|
-
// First child margin-top collapses through parent if parent has no top border/padding
|
|
2158
|
-
// Only for elements that don't establish a new BFC (not root, not flex, not overflow)
|
|
2358
|
+
const childMarginTop = collapsibleMarginTop(child);
|
|
2159
2359
|
// First child margin-top collapses through parent if parent has no
|
|
2160
2360
|
// top padding/border and doesn't establish a new BFC.
|
|
2161
2361
|
if (!hasContent && padTop === 0 && borderTop === 0 && allowCollapseThrough) {
|
|
@@ -2175,16 +2375,18 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
|
|
|
2175
2375
|
// Last child's margin-bottom collapses through parent if no bottom border/padding.
|
|
2176
2376
|
// Root container does NOT collapse last-child margin (it defines the content height).
|
|
2177
2377
|
let marginBottomOut = style.marginBottom;
|
|
2178
|
-
const canCollapseThrough = padBottom === 0 && borderBottom === 0 &&
|
|
2179
|
-
|
|
2378
|
+
const canCollapseThrough = padBottom === 0 && borderBottom === 0 &&
|
|
2379
|
+
style.minHeight === 0 && allowCollapseThrough;
|
|
2380
|
+
if (canCollapseThrough) {
|
|
2180
2381
|
// Last child's margin passes through to become parent's effective margin-bottom
|
|
2181
|
-
marginBottomOut =
|
|
2382
|
+
marginBottomOut = collapseMargins(style.marginBottom, prevMarginBottom);
|
|
2182
2383
|
}
|
|
2183
2384
|
// Include last child's margin-bottom in parent height when it can't collapse through
|
|
2184
2385
|
let contentEnd = curY - contentStartY;
|
|
2185
|
-
if (!canCollapseThrough
|
|
2386
|
+
if (!canCollapseThrough) {
|
|
2186
2387
|
contentEnd += prevMarginBottom;
|
|
2187
2388
|
}
|
|
2389
|
+
contentEnd = Math.max(0, contentEnd);
|
|
2188
2390
|
box.height = borderTop + padTop + contentEnd + padBottom + borderBottom;
|
|
2189
2391
|
if (style.minHeight > 0)
|
|
2190
2392
|
box.height = Math.max(box.height, style.minHeight);
|
|
@@ -2240,25 +2442,227 @@ function layoutTable(ctx, node, contentX, contentY, contentWidth) {
|
|
|
2240
2442
|
return { children, height: curY - contentY };
|
|
2241
2443
|
}
|
|
2242
2444
|
// ─── Flex layout ───────────────────────────────────────────────────────
|
|
2445
|
+
const _anonymousFlexItems = new WeakMap();
|
|
2446
|
+
/**
|
|
2447
|
+
* Bare text in a flex container is an anonymous flex item: a block box of its
|
|
2448
|
+
* own, sized and placed like any other. Built once per text node, because the
|
|
2449
|
+
* min- and max-content caches are keyed by node identity and sizing must ask
|
|
2450
|
+
* about the very node the layout places.
|
|
2451
|
+
*/
|
|
2452
|
+
function anonymousFlexItem(text) {
|
|
2453
|
+
let wrapper = _anonymousFlexItems.get(text);
|
|
2454
|
+
if (!wrapper) {
|
|
2455
|
+
wrapper = {
|
|
2456
|
+
element: null,
|
|
2457
|
+
tagName: 'div',
|
|
2458
|
+
style: { ...text.style, display: 'block' },
|
|
2459
|
+
children: [text],
|
|
2460
|
+
textContent: null,
|
|
2461
|
+
};
|
|
2462
|
+
_anonymousFlexItems.set(text, wrapper);
|
|
2463
|
+
}
|
|
2464
|
+
return wrapper;
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* The children a flex container lays out. A bare text node is an anonymous
|
|
2468
|
+
* flex item only when it has actual text — and min-content sizing has to agree
|
|
2469
|
+
* with the layout about that, or an item is frozen at the wrong minimum.
|
|
2470
|
+
*/
|
|
2471
|
+
function flexItems(node) {
|
|
2472
|
+
return node.children
|
|
2473
|
+
.filter((child) => child.tagName !== '#text' || child.textContent?.trim())
|
|
2474
|
+
.map((child) => child.tagName === '#text' ? anonymousFlexItem(child) : child);
|
|
2475
|
+
}
|
|
2476
|
+
function isFlexRow(style) {
|
|
2477
|
+
return style.flexDirection === 'row' || style.flexDirection === '';
|
|
2478
|
+
}
|
|
2479
|
+
function horizontalFrame(style) {
|
|
2480
|
+
return style.borderLeftWidth + style.paddingLeft +
|
|
2481
|
+
style.paddingRight + style.borderRightWidth;
|
|
2482
|
+
}
|
|
2483
|
+
function horizontalMargins(style) {
|
|
2484
|
+
return style.marginLeft + style.marginRight;
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Minimum width of one inline formatting context. This is the longest unit
|
|
2488
|
+
* between normal soft-wrap opportunities, measured with the same canvas
|
|
2489
|
+
* context and tokenization as the actual line flow.
|
|
2490
|
+
*/
|
|
2491
|
+
function minimumInlineContentWidth(ctx, node) {
|
|
2492
|
+
// `overflow-wrap:break-word` is deliberately ignored for min-content sizing
|
|
2493
|
+
// by CSS. CJK/emoji and `word-break:break-all` still contribute their
|
|
2494
|
+
// smallest legal pieces, so run the real line flow with only that
|
|
2495
|
+
// last-resort mode disabled — at a width nothing fits in, every soft-wrap
|
|
2496
|
+
// opportunity is taken and each line IS one unbreakable unit.
|
|
2497
|
+
const words = tokenizeRuns(ctx, collectTextRuns(node)).map((word) => word.style.overflowWrap === 'break-word' && word.style.wordBreak !== 'break-all'
|
|
2498
|
+
? { ...word, style: { ...word.style, overflowWrap: 'normal' } }
|
|
2499
|
+
: word);
|
|
2500
|
+
const lines = flowWordsIntoLines(ctx, words, 0, node.style.whiteSpace);
|
|
2501
|
+
return lines.reduce((widest, line) => Math.max(widest, line.totalWidth), 0);
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Min-content contribution of a flex item, including its horizontal frame.
|
|
2505
|
+
*
|
|
2506
|
+
* Memoized for the render: a nested flex row asks for the minimum of its whole
|
|
2507
|
+
* subtree, and so does every flex row above it, which otherwise costs
|
|
2508
|
+
* O(depth x nodes). The answer depends only on the subtree and the font state,
|
|
2509
|
+
* and `buildLayoutTree` clears the cache alongside the measurement caches.
|
|
2510
|
+
*/
|
|
2511
|
+
function minimumContentWidth(ctx, node) {
|
|
2512
|
+
const memoized = _minContentCache.get(node);
|
|
2513
|
+
if (memoized !== undefined)
|
|
2514
|
+
return memoized;
|
|
2515
|
+
const computed = computeMinimumContentWidth(ctx, node);
|
|
2516
|
+
_minContentCache.set(node, computed);
|
|
2517
|
+
return computed;
|
|
2518
|
+
}
|
|
2519
|
+
function computeMinimumContentWidth(ctx, node) {
|
|
2520
|
+
const margins = horizontalMargins(node.style);
|
|
2521
|
+
const frame = horizontalFrame(node.style);
|
|
2522
|
+
// An explicit min-width disables the flex automatic min-content size.
|
|
2523
|
+
if (node.style.minWidth !== null) {
|
|
2524
|
+
return margins + Math.max(frame, node.style.minWidth);
|
|
2525
|
+
}
|
|
2526
|
+
let content = 0;
|
|
2527
|
+
if (hasOnlyInlineChildren(node)) {
|
|
2528
|
+
content = minimumInlineContentWidth(ctx, node);
|
|
2529
|
+
}
|
|
2530
|
+
else if (node.style.display === 'flex' && isFlexRow(node.style)) {
|
|
2531
|
+
const children = flexItems(node);
|
|
2532
|
+
content = children.reduce((sum, child) => sum + minimumContentWidth(ctx, child), 0) +
|
|
2533
|
+
node.style.gap * Math.max(0, children.length - 1);
|
|
2534
|
+
}
|
|
2535
|
+
else {
|
|
2536
|
+
for (const child of node.children) {
|
|
2537
|
+
if (child.tagName !== '#text') {
|
|
2538
|
+
content = Math.max(content, minimumContentWidth(ctx, child));
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
let borderBox = frame + content;
|
|
2543
|
+
// A definite width caps the automatic minimum size in the flex algorithm.
|
|
2544
|
+
if (node.style.width > 0)
|
|
2545
|
+
borderBox = Math.min(borderBox, node.style.width);
|
|
2546
|
+
return margins + borderBox;
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Maximum width of one inline formatting context: the widest stretch between
|
|
2550
|
+
* FORCED breaks. That is the same line flow every other caller uses, run at a
|
|
2551
|
+
* width nothing can exceed — max-content does not get its own break rules.
|
|
2552
|
+
*/
|
|
2553
|
+
function maximumInlineContentWidth(ctx, node) {
|
|
2554
|
+
const words = tokenizeRuns(ctx, collectTextRuns(node));
|
|
2555
|
+
const lines = flowWordsIntoLines(ctx, words, Infinity, node.style.whiteSpace);
|
|
2556
|
+
return lines.reduce((widest, line) => Math.max(widest, line.totalWidth), 0);
|
|
2557
|
+
}
|
|
2558
|
+
/**
|
|
2559
|
+
* Max-content contribution of a flex item, including its horizontal frame and
|
|
2560
|
+
* margins — the same outer currency `minimumContentWidth` reports and
|
|
2561
|
+
* `layoutBlock` takes as its available width.
|
|
2562
|
+
*
|
|
2563
|
+
* Memoized for the same reason the minimum is: every flex row above an item
|
|
2564
|
+
* asks for its whole subtree.
|
|
2565
|
+
*/
|
|
2566
|
+
function maximumContentWidth(ctx, node) {
|
|
2567
|
+
const memoized = _maxContentCache.get(node);
|
|
2568
|
+
if (memoized !== undefined)
|
|
2569
|
+
return memoized;
|
|
2570
|
+
const computed = computeMaximumContentWidth(ctx, node);
|
|
2571
|
+
_maxContentCache.set(node, computed);
|
|
2572
|
+
return computed;
|
|
2573
|
+
}
|
|
2574
|
+
function computeMaximumContentWidth(ctx, node) {
|
|
2575
|
+
const margins = horizontalMargins(node.style);
|
|
2576
|
+
// A definite width IS the max-content size.
|
|
2577
|
+
if (node.style.width > 0)
|
|
2578
|
+
return margins + node.style.width;
|
|
2579
|
+
let content = 0;
|
|
2580
|
+
if (hasOnlyInlineChildren(node)) {
|
|
2581
|
+
content = maximumInlineContentWidth(ctx, node);
|
|
2582
|
+
}
|
|
2583
|
+
else if (node.style.display === 'flex' && isFlexRow(node.style)) {
|
|
2584
|
+
const children = flexItems(node);
|
|
2585
|
+
content = children.reduce((sum, child) => sum + maximumContentWidth(ctx, child), 0) +
|
|
2586
|
+
node.style.gap * Math.max(0, children.length - 1);
|
|
2587
|
+
}
|
|
2588
|
+
else {
|
|
2589
|
+
for (const child of node.children) {
|
|
2590
|
+
if (child.tagName !== '#text') {
|
|
2591
|
+
content = Math.max(content, maximumContentWidth(ctx, child));
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
return margins + horizontalFrame(node.style) + content;
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Flex base size of one item, as an outer width. `flex-basis: auto` (the
|
|
2599
|
+
* initial value, and what `flex-grow: 1` on its own leaves in place) resolves
|
|
2600
|
+
* against the item's own content; `flex: 1` sets it to 0 so the item's content
|
|
2601
|
+
* stops mattering and the row splits by grow factor alone.
|
|
2602
|
+
*/
|
|
2603
|
+
function flexBaseSize(ctx, node) {
|
|
2604
|
+
return node.style.flexBasis !== null
|
|
2605
|
+
? horizontalMargins(node.style) + node.style.flexBasis
|
|
2606
|
+
: maximumContentWidth(ctx, node);
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* CSS flexible length resolution (CSS Flexbox §9.7) over outer widths.
|
|
2610
|
+
*
|
|
2611
|
+
* Grow or shrink is decided once, for the whole line, by whether the items'
|
|
2612
|
+
* hypothetical sizes fit. Each pass distributes the space the unfrozen items
|
|
2613
|
+
* are still free to take, then freezes every item that landed under its
|
|
2614
|
+
* automatic minimum — freeing one item changes every other item's share, so
|
|
2615
|
+
* the pass repeats until nothing new is clamped.
|
|
2616
|
+
*/
|
|
2617
|
+
function resolveFlexibleLengths(styles, bases, minimums, available) {
|
|
2618
|
+
const sizes = bases.map((base, index) => Math.max(base, minimums[index]));
|
|
2619
|
+
const growing = sizes.reduce((sum, size) => sum + size, 0) < available;
|
|
2620
|
+
const factor = (index) => growing ? styles[index].flexGrow : styles[index].flexShrink;
|
|
2621
|
+
const frozen = sizes.map((size, index) => factor(index) === 0 || (!growing && bases[index] < size));
|
|
2622
|
+
for (;;) {
|
|
2623
|
+
const unfrozen = sizes.map((_, index) => index).filter((index) => !frozen[index]);
|
|
2624
|
+
if (unfrozen.length === 0)
|
|
2625
|
+
break;
|
|
2626
|
+
const used = sizes.reduce((sum, size, index) => sum + (frozen[index] ? size : bases[index]), 0);
|
|
2627
|
+
const remaining = available - used;
|
|
2628
|
+
// Shrinking is weighted by base size, so a big item gives up more than a
|
|
2629
|
+
// small one at the same shrink factor; growing is not.
|
|
2630
|
+
const weights = unfrozen.map((index) => growing ? styles[index].flexGrow : styles[index].flexShrink * bases[index]);
|
|
2631
|
+
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
|
2632
|
+
if (weightSum <= 0)
|
|
2633
|
+
break;
|
|
2634
|
+
unfrozen.forEach((index, slot) => {
|
|
2635
|
+
sizes[index] = bases[index] + remaining * weights[slot] / weightSum;
|
|
2636
|
+
});
|
|
2637
|
+
const violators = unfrozen.filter((index) => sizes[index] < minimums[index]);
|
|
2638
|
+
if (violators.length === 0)
|
|
2639
|
+
break;
|
|
2640
|
+
for (const index of violators) {
|
|
2641
|
+
sizes[index] = minimums[index];
|
|
2642
|
+
frozen[index] = true;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
return sizes;
|
|
2646
|
+
}
|
|
2243
2647
|
function layoutFlex(ctx, node, contentX, contentY, contentWidth) {
|
|
2244
2648
|
const style = node.style;
|
|
2245
2649
|
const gap = style.gap;
|
|
2246
2650
|
const children = [];
|
|
2247
|
-
const flexChildren = node
|
|
2651
|
+
const flexChildren = flexItems(node);
|
|
2248
2652
|
if (flexChildren.length === 0)
|
|
2249
2653
|
return { children, height: 0 };
|
|
2250
|
-
if (style
|
|
2654
|
+
if (isFlexRow(style)) {
|
|
2251
2655
|
// Row layout
|
|
2252
2656
|
const totalGaps = gap * (flexChildren.length - 1);
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2657
|
+
const available = Math.max(0, contentWidth - totalGaps);
|
|
2658
|
+
// If the minima themselves do not fit, they overflow the container exactly
|
|
2659
|
+
// as native flex items with min-width:auto do.
|
|
2660
|
+
const widths = resolveFlexibleLengths(flexChildren.map((child) => child.style), flexChildren.map((child) => flexBaseSize(ctx, child)), flexChildren.map((child) => minimumContentWidth(ctx, child)), available);
|
|
2255
2661
|
let curX = contentX;
|
|
2256
2662
|
let maxHeight = 0;
|
|
2257
|
-
for (
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
const grow = child.style.flexGrow || (totalGrow === 0 ? 1 : 0);
|
|
2261
|
-
const childWidth = flexBasis * grow;
|
|
2663
|
+
for (let index = 0; index < flexChildren.length; index++) {
|
|
2664
|
+
const child = flexChildren[index];
|
|
2665
|
+
const childWidth = widths[index];
|
|
2262
2666
|
const { box, height } = layoutBlock(ctx, child, curX, contentY, childWidth);
|
|
2263
2667
|
children.push(box);
|
|
2264
2668
|
maxHeight = Math.max(maxHeight, height);
|
|
@@ -2269,8 +2673,6 @@ function layoutFlex(ctx, node, contentX, contentY, contentWidth) {
|
|
|
2269
2673
|
// Column layout (fallback)
|
|
2270
2674
|
let curY = contentY;
|
|
2271
2675
|
for (const child of flexChildren) {
|
|
2272
|
-
if (child.tagName === '#text')
|
|
2273
|
-
continue;
|
|
2274
2676
|
const { box, height } = layoutBlock(ctx, child, contentX, curY, contentWidth);
|
|
2275
2677
|
children.push(box);
|
|
2276
2678
|
curY += height + gap;
|
|
@@ -2414,6 +2816,8 @@ export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasureme
|
|
|
2414
2816
|
_fontMetricsCache.clear();
|
|
2415
2817
|
_fontStringCache.clear();
|
|
2416
2818
|
_measureCache.clear();
|
|
2819
|
+
_minContentCache.clear();
|
|
2820
|
+
_maxContentCache.clear();
|
|
2417
2821
|
_lines = [];
|
|
2418
2822
|
// The styledTree root is our container div — layout its children as a block flow
|
|
2419
2823
|
const { box, height } = layoutBlock(ctx, styledTree, 0, 0, containerWidth);
|
|
@@ -2426,11 +2830,13 @@ export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasureme
|
|
|
2426
2830
|
const lines = [];
|
|
2427
2831
|
for (const candidate of sorted) {
|
|
2428
2832
|
const last = lines[lines.length - 1];
|
|
2429
|
-
//
|
|
2430
|
-
//
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
2433
|
-
|
|
2833
|
+
// How far apart two baselines can sit and still be one visual row is
|
|
2834
|
+
// bounded by the SHORTER of the two rows — the same rule the native DOM
|
|
2835
|
+
// reference groups word rects by (`overlap / minH`). Using max() leaks
|
|
2836
|
+
// across rows in tight multi-column layouts, and using the candidate's own
|
|
2837
|
+
// height alone lets a line box that contains a tall atomic inline-block
|
|
2838
|
+
// swallow the row above it.
|
|
2839
|
+
const tolerance = Math.min(last?.bounds.height ?? Infinity, candidate.bounds.height) * 0.5;
|
|
2434
2840
|
if (last && Math.abs(candidate.y - last.y) < tolerance) {
|
|
2435
2841
|
// Cross-cell merge: insert a space separator so the text stays
|
|
2436
2842
|
// readable when N cells of a table row collapse into one LayoutLine.
|