render-tag 0.1.33 → 0.1.35

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/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
- * Check if a line has mixed fonts (different fontFamily/fontSize/fontWeight/fontStyle).
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 hasMixedFonts(words) {
27
- let font = '';
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 f = buildCanvasFont(w.style);
32
- if (font && f !== font)
36
+ const key = `${buildCanvasFont(w.style)}|${formatLetterSpacing(w.style.letterSpacing)}` +
37
+ `|${canvasKerning(w.style)}`;
38
+ if (metrics && key !== metrics)
33
39
  return true;
34
- font = f;
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.fontKerning === 'none' ? 'none' : 'normal';
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.split(/( +|\t)/);
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) ? [w] : w.split(/(?<=\?)(?=.)/));
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 hasEmoji = EMOJI_PICTOGRAPHIC.test(word.text) && !!getGraphemeSegmenter();
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 a LAST RESORT the browser first uses any
953
- // normal break opportunity inside the word (a hyphen) before breaking
954
- // mid-character. So split a hyphenated word at its hyphens first and only
955
- // char-break the segments that are themselves still too wide. (word-break:
956
- // break-all genuinely allows breaking between any two characters, so it
957
- // skips this and falls through to the char loop below.)
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: cachedMeasureWidth(ctx, char) });
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 = cachedMeasureWidth(ctx, candidateText);
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 = cachedMeasureWidth(ctx, char);
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.boxStyle && word.boxStyle.display === 'inline-block') {
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.split(/(?<=-)(?!\d)|(?<=[^\d]-)/).filter((s) => s.length);
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 pieces = (!word.isSpace && word.text.length > 1)
1381
+ const broken = (!word.isSpace && word.text.length > 1)
1272
1382
  ? breakWordIfNeeded(ctx, word, effWidth(), currentLine.totalWidth)
1273
- : [word];
1274
- // Glued tail: content immediately after this word that cannot start a new
1275
- // line trailing punctuation (",.)]}…"), an inline span's right
1276
- // padding/border (empty boxClose markers), and a word continuation that
1277
- // abuts this word across a run boundary with no soft-wrap opportunity
1278
- // (noBreakBefore e.g. one word split across two inline spans with
1279
- // different font sizes). The browser includes all of it when deciding
1280
- // whether this word fits, so the unit wraps together: if "Music Experie"
1281
- // doesn't leave room for the glued "nce", the whole word wraps as one.
1282
- // Stops at whitespace or the next breakable word.
1283
- let gluedTailWidth = 0;
1284
- for (let j = wordIndex + 1; j < words.length; j++) {
1285
- const nw = words[j];
1286
- if (nw.isSpace || nw.text === '\n')
1287
- break;
1288
- const isPunct = !!nw.text && TRAILING_PUNCT.test(nw.text);
1289
- const isCloseMarker = !nw.text && !!nw.boxClose;
1290
- const isGluedCont = !!nw.text && !!nw.noBreakBefore;
1291
- if (isPunct || isCloseMarker || isGluedCont) {
1292
- gluedTailWidth += nw.width;
1293
- continue;
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 = piece === pieces[0] && piece.noBreakBefore &&
1312
- currentLine.words.length > 0 &&
1313
- !currentLine.words[currentLine.words.length - 1].isSpace;
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
- if (!piece.text && piece.boxOpen) {
1321
- const next = words[wordIndex + 1];
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 = np[0].width;
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
- currentLine.totalWidth + piece.width + shReserve + tail + headExtra > effWidth()) {
1347
- const overflow = currentLine.totalWidth + piece.width + shReserve + tail + headExtra - effWidth();
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
- if (overflow < 1 && !hasMixedFonts([...currentLine.words, piece])) {
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
- const fullWidth = cachedMeasureWidth(ctx, fullText) + markerWidth + tail + headExtra;
1368
- // Allow only a hair of sub-pixel overflow. measureText matches the
1369
- // browser's rendered width to ~0.01px, so a larger slack would keep
1370
- // lines the browser actually wraps (packing one extra word per
1371
- // borderline line and drifting the whole document's breaks).
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
- // Tab stops follow the BLOCK's style, not the inline run the tab sits in:
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 (atomic) {
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
- const boxY = lineBaselineY - boxAscent - padTop;
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
- let boxHasText = false;
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 (boxHasText)
1721
- emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
1844
+ if (boxTextWord)
1845
+ emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX, boxTextWord);
1722
1846
  currentBoxStyle = undefined;
1723
- boxHasText = false;
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
- const boxW = s.borderLeftWidth + s.paddingLeft + textWidth + s.paddingRight + s.borderRightWidth;
1730
- emitInlineBox(s, boxX, boxW);
1731
- boxHasText = false;
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 && boxHasText) {
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
- boxHasText = false;
1879
+ boxTextWord = undefined;
1742
1880
  }
1743
1881
  if (word.text && !word.isSpace)
1744
- boxHasText = true;
1882
+ boxTextWord ?? (boxTextWord = word);
1745
1883
  scanX += word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1746
1884
  }
1747
- if (currentBoxStyle && boxHasText) {
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
- _lines.push({
2107
+ const emittedLine = {
1926
2108
  y: Math.round(lineBaselineY),
1927
- text: line.words.map(w => w.text).join(''),
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
@@ -2115,7 +2301,8 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
2115
2301
  if (hasOnlyInlineChildren(node)) {
2116
2302
  // Inline formatting context
2117
2303
  const bulletProbe = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
2118
- 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);
2119
2306
  box.children = nodes;
2120
2307
  box.height = borderTop + padTop + height + padBottom + borderBottom;
2121
2308
  }
@@ -2159,7 +2346,8 @@ function layoutBlock(ctx, node, x, y, availableWidth, clamp) {
2159
2346
  textContent: null,
2160
2347
  };
2161
2348
  const bulletProbe2 = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
2162
- 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);
2163
2351
  box.children.push(...nodes);
2164
2352
  curY += height;
2165
2353
  prevMarginBottom = 0;
@@ -2254,25 +2442,227 @@ function layoutTable(ctx, node, contentX, contentY, contentWidth) {
2254
2442
  return { children, height: curY - contentY };
2255
2443
  }
2256
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
+ }
2257
2647
  function layoutFlex(ctx, node, contentX, contentY, contentWidth) {
2258
2648
  const style = node.style;
2259
2649
  const gap = style.gap;
2260
2650
  const children = [];
2261
- const flexChildren = node.children.filter(c => c.tagName !== '#text' || c.textContent?.trim());
2651
+ const flexChildren = flexItems(node);
2262
2652
  if (flexChildren.length === 0)
2263
2653
  return { children, height: 0 };
2264
- if (style.flexDirection === 'row' || style.flexDirection === '') {
2654
+ if (isFlexRow(style)) {
2265
2655
  // Row layout
2266
2656
  const totalGaps = gap * (flexChildren.length - 1);
2267
- const totalGrow = flexChildren.reduce((s, c) => s + (c.style.flexGrow || 0), 0);
2268
- const flexBasis = (contentWidth - totalGaps) / (totalGrow || flexChildren.length);
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);
2269
2661
  let curX = contentX;
2270
2662
  let maxHeight = 0;
2271
- for (const child of flexChildren) {
2272
- if (child.tagName === '#text')
2273
- continue;
2274
- const grow = child.style.flexGrow || (totalGrow === 0 ? 1 : 0);
2275
- const childWidth = flexBasis * grow;
2663
+ for (let index = 0; index < flexChildren.length; index++) {
2664
+ const child = flexChildren[index];
2665
+ const childWidth = widths[index];
2276
2666
  const { box, height } = layoutBlock(ctx, child, curX, contentY, childWidth);
2277
2667
  children.push(box);
2278
2668
  maxHeight = Math.max(maxHeight, height);
@@ -2283,8 +2673,6 @@ function layoutFlex(ctx, node, contentX, contentY, contentWidth) {
2283
2673
  // Column layout (fallback)
2284
2674
  let curY = contentY;
2285
2675
  for (const child of flexChildren) {
2286
- if (child.tagName === '#text')
2287
- continue;
2288
2676
  const { box, height } = layoutBlock(ctx, child, contentX, curY, contentWidth);
2289
2677
  children.push(box);
2290
2678
  curY += height + gap;
@@ -2428,6 +2816,8 @@ export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasureme
2428
2816
  _fontMetricsCache.clear();
2429
2817
  _fontStringCache.clear();
2430
2818
  _measureCache.clear();
2819
+ _minContentCache.clear();
2820
+ _maxContentCache.clear();
2431
2821
  _lines = [];
2432
2822
  // The styledTree root is our container div — layout its children as a block flow
2433
2823
  const { box, height } = layoutBlock(ctx, styledTree, 0, 0, containerWidth);
@@ -2440,11 +2830,13 @@ export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasureme
2440
2830
  const lines = [];
2441
2831
  for (const candidate of sorted) {
2442
2832
  const last = lines[lines.length - 1];
2443
- // Tolerance keys off the candidate's line height (matches the legacy
2444
- // extractLines behavior). Using max(last, candidate) is symmetric but
2445
- // grows after each merge as last.bounds.height becomes the union that
2446
- // leaks across rows in tight multi-column layouts.
2447
- const tolerance = candidate.bounds.height * 0.5;
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;
2448
2840
  if (last && Math.abs(candidate.y - last.y) < tolerance) {
2449
2841
  // Cross-cell merge: insert a space separator so the text stays
2450
2842
  // readable when N cells of a table row collapse into one LayoutLine.