1import { prepareWithSegments, } from './layout.js';2import { buildLineTextFromRange, getLineTextCache, } from './line-text.js';3import { stepPreparedLineGeometry, } from './line-break.js';4import { getFontMeasurementState, getSegmentMetrics } from './measurement.js';5const EMPTY_LAYOUT_CURSOR = { segmentIndex: 0, graphemeIndex: 0 };6const RICH_INLINE_START_CURSOR = {7 itemIndex: 0,8 segmentIndex: 0,9 graphemeIndex: 0,10};11function getInternalPreparedRichInline(prepared) {12 return prepared;13}14function cloneCursor(cursor) {15 return {16 segmentIndex: cursor.segmentIndex,17 graphemeIndex: cursor.graphemeIndex,18 };19}20function isLineStartCursor(cursor) {21 return cursor.segmentIndex === 0 && cursor.graphemeIndex === 0;22}23function isCollapsibleBoundaryWhitespace(code) {24 return code === 0x20 || code === 0x09 || code === 0x0A || code === 0x0C || code === 0x0D;25}26function getCollapsedSpaceWidth(font, letterSpacing) {27 const { cache } = getFontMeasurementState(font, false);28 return getSegmentMetrics(' ', cache).width + letterSpacing;29}30function measureWholeItem(prepared) {31 const end = { segmentIndex: 0, graphemeIndex: 0 };32 return stepPreparedLineGeometry(prepared, end, Number.POSITIVE_INFINITY);33}34function endsInsideFirstSegment(segmentIndex, graphemeIndex) {35 return segmentIndex === 0 && graphemeIndex > 0;36}37export function prepareRichInline(items) {38 const preparedItems = Array.from({ length: items.length });39 // A collapsed SPACE can have zero or negative advance. Its existence and40 // ordinary break opportunity must survive independently of that number.41 let pendingGapWidth = null;42 let breakAfterPreviousItem = false;43 for (let index = 0; index < items.length; index++) {44 const item = items[index];45 const letterSpacing = item.letterSpacing ?? 0;46 let start = 0;47 while (start < item.text.length && isCollapsibleBoundaryWhitespace(item.text.charCodeAt(start)))48 start++;49 if (start === item.text.length) {50 if (start > 0 && pendingGapWidth === null) {51 pendingGapWidth = getCollapsedSpaceWidth(item.font, letterSpacing);52 }53 continue;54 }55 // Scan from the ends once. A trailing-whitespace regex retries every56 // position in a long internal space run when later content prevents a match.57 let end = item.text.length;58 while (end > start && isCollapsibleBoundaryWhitespace(item.text.charCodeAt(end - 1)))59 end--;60 const hasLeadingWhitespace = start > 0;61 const hasTrailingWhitespace = end < item.text.length;62 const trimmedText = item.text.slice(start, end);63 const gapBefore = pendingGapWidth ?? (hasLeadingWhitespace ? getCollapsedSpaceWidth(item.font, letterSpacing) : 0);64 const prepared = prepareWithSegments(trimmedText, item.font, letterSpacing === 0 ? undefined : { letterSpacing });65 // The flat walker can omit source controls at line start. Its result is66 // a measurement observation, not the rich item's identity or source end.67 const wholeWidth = measureWholeItem(prepared);68 const establishesLine = wholeWidth !== null || prepared.kinds.includes('zero-width-break');69 const preparedItem = {70 break: item.break ?? 'normal',71 breakBefore: pendingGapWidth !== null || hasLeadingWhitespace || breakAfterPreviousItem,72 establishesLine,73 extraWidth: item.extraWidth ?? 0,74 gapBefore,75 naturalWidth: wholeWidth ?? 0,76 prepared,77 };78 preparedItems[index] = preparedItem;79 if (establishesLine)80 breakAfterPreviousItem = prepared.kinds.at(-1) === 'zero-width-break';81 pendingGapWidth = hasTrailingWhitespace82 ? getCollapsedSpaceWidth(item.font, letterSpacing)83 : null;84 }85 return {86 items: preparedItems,87 };88}89function stepRichInlineLine(flow, maxWidth, cursor, collectFragment) {90 if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length)91 return null;92 const safeWidth = Math.max(1, maxWidth);93 let hasContent = false;94 let lineWidth = 0;95 let remainingWidth = safeWidth;96 let itemIndex = cursor.itemIndex;97 lineLoop: while (itemIndex < flow.items.length) {98 const item = flow.items[itemIndex];99 if (item === undefined) {100 itemIndex++;101 cursor.segmentIndex = 0;102 cursor.graphemeIndex = 0;103 continue;104 }105 if (!isLineStartCursor(cursor) &&106 cursor.segmentIndex === item.prepared.segments.length &&107 cursor.graphemeIndex === 0) {108 itemIndex++;109 cursor.segmentIndex = 0;110 cursor.graphemeIndex = 0;111 continue;112 }113 // Retain inactive source items in the original coordinate space without114 // turning their mere presence into a line. Their prior layout behavior is115 // unchanged; a following line can still expose their consumed source.116 if (!item.establishesLine) {117 collectFragment?.(itemIndex, 0, 0, cloneCursor(EMPTY_LAYOUT_CURSOR), {118 segmentIndex: item.prepared.segments.length,119 graphemeIndex: 0,120 });121 itemIndex++;122 cursor.segmentIndex = 0;123 cursor.graphemeIndex = 0;124 continue;125 }126 const gapBefore = hasContent ? item.gapBefore : 0;127 const atItemStart = isLineStartCursor(cursor);128 if (item.break === 'never') {129 if (!atItemStart) {130 itemIndex++;131 cursor.segmentIndex = 0;132 cursor.graphemeIndex = 0;133 continue;134 }135 const occupiedWidth = item.naturalWidth + item.extraWidth;136 const totalWidth = gapBefore + occupiedWidth;137 if (hasContent && totalWidth > remainingWidth)138 break lineLoop;139 collectFragment?.(itemIndex, gapBefore, occupiedWidth, cloneCursor(EMPTY_LAYOUT_CURSOR), {140 segmentIndex: item.prepared.segments.length,141 graphemeIndex: 0,142 });143 hasContent = true;144 lineWidth += totalWidth;145 remainingWidth = safeWidth - lineWidth;146 itemIndex++;147 cursor.segmentIndex = 0;148 cursor.graphemeIndex = 0;149 continue;150 }151 const reservedWidth = gapBefore + item.extraWidth;152 if (hasContent && reservedWidth > remainingWidth)153 break lineLoop;154 if (atItemStart) {155 const totalWidth = reservedWidth + item.naturalWidth;156 if (totalWidth <= remainingWidth) {157 collectFragment?.(itemIndex, gapBefore, item.naturalWidth + item.extraWidth, cloneCursor(EMPTY_LAYOUT_CURSOR), {158 segmentIndex: item.prepared.segments.length,159 graphemeIndex: 0,160 });161 hasContent = true;162 lineWidth += totalWidth;163 remainingWidth = safeWidth - lineWidth;164 itemIndex++;165 cursor.segmentIndex = 0;166 cursor.graphemeIndex = 0;167 continue;168 }169 }170 const availableWidth = Math.max(1, remainingWidth - reservedWidth);171 const lineEnd = {172 segmentIndex: cursor.segmentIndex,173 graphemeIndex: cursor.graphemeIndex,174 };175 const lineWidthForItem = stepPreparedLineGeometry(item.prepared, lineEnd, availableWidth);176 if (lineWidthForItem === null) {177 itemIndex++;178 cursor.segmentIndex = 0;179 cursor.graphemeIndex = 0;180 continue;181 }182 if (cursor.segmentIndex === lineEnd.segmentIndex &&183 cursor.graphemeIndex === lineEnd.graphemeIndex) {184 itemIndex++;185 cursor.segmentIndex = 0;186 cursor.graphemeIndex = 0;187 continue;188 }189 const itemOccupiedWidth = lineWidthForItem + item.extraWidth;190 const lineWidthContribution = gapBefore + itemOccupiedWidth;191 // The lower-level walker may force one unit to make progress. If that unit192 // only fits on a fresh line, wrap before this rich item instead.193 if (hasContent && atItemStart && lineWidthContribution > remainingWidth)194 break lineLoop;195 // Preserve the ordinary item-boundary opportunity before emergency196 // splitting the next word. SPACE advance need not be positive; ZWSP has197 // no gap at all.198 if (hasContent && atItemStart && item.breakBefore && endsInsideFirstSegment(lineEnd.segmentIndex, lineEnd.graphemeIndex)) {199 break lineLoop;200 }201 collectFragment?.(itemIndex, gapBefore, itemOccupiedWidth, cloneCursor(cursor), {202 segmentIndex: lineEnd.segmentIndex,203 graphemeIndex: lineEnd.graphemeIndex,204 });205 hasContent = true;206 lineWidth += lineWidthContribution;207 remainingWidth = safeWidth - lineWidth;208 if (lineEnd.segmentIndex === item.prepared.segments.length &&209 lineEnd.graphemeIndex === 0) {210 itemIndex++;211 cursor.segmentIndex = 0;212 cursor.graphemeIndex = 0;213 continue;214 }215 cursor.segmentIndex = lineEnd.segmentIndex;216 cursor.graphemeIndex = lineEnd.graphemeIndex;217 break;218 }219 if (!hasContent)220 return null;221 cursor.itemIndex = itemIndex;222 return lineWidth;223}224export function layoutNextRichInlineLineRange(prepared, maxWidth, start = RICH_INLINE_START_CURSOR) {225 const flow = getInternalPreparedRichInline(prepared);226 const end = {227 itemIndex: start.itemIndex,228 segmentIndex: start.segmentIndex,229 graphemeIndex: start.graphemeIndex,230 };231 const fragments = [];232 const width = stepRichInlineLine(flow, maxWidth, end, (itemIndex, gapBefore, occupiedWidth, fragmentStart, fragmentEnd) => {233 fragments.push({234 itemIndex,235 gapBefore,236 occupiedWidth,237 start: fragmentStart,238 end: fragmentEnd,239 });240 });241 if (width === null)242 return null;243 return {244 fragments,245 width,246 end,247 };248}249function materializeFragmentText(item, fragment) {250 return buildLineTextFromRange(item.prepared, getLineTextCache(item.prepared), fragment.start.segmentIndex, fragment.start.graphemeIndex, fragment.end.segmentIndex, fragment.end.graphemeIndex);251}252// Bridge from cheap range walking to full fragment text. Lets callers do253// shrinkwrap/virtualization/probing work first, then only pay for text on the254// lines they actually render.255export function materializeRichInlineLineRange(prepared, line) {256 const flow = getInternalPreparedRichInline(prepared);257 const fragments = [];258 for (let i = 0; i < line.fragments.length; i++) {259 const fragment = line.fragments[i];260 const item = flow.items[fragment.itemIndex];261 if (item === undefined)262 throw new Error('Missing rich-text inline item for fragment');263 fragments.push({264 itemIndex: fragment.itemIndex,265 text: materializeFragmentText(item, fragment),266 gapBefore: fragment.gapBefore,267 occupiedWidth: fragment.occupiedWidth,268 start: fragment.start,269 end: fragment.end,270 });271 }272 return {273 fragments,274 width: line.width,275 end: line.end,276 };277}278export function walkRichInlineLineRanges(prepared, maxWidth, onLine) {279 let lineCount = 0;280 const cursor = { ...RICH_INLINE_START_CURSOR };281 while (true) {282 const line = layoutNextRichInlineLineRange(prepared, maxWidth, cursor);283 if (line === null)284 return lineCount;285 cursor.itemIndex = line.end.itemIndex;286 cursor.segmentIndex = line.end.segmentIndex;287 cursor.graphemeIndex = line.end.graphemeIndex;288 onLine(line);289 lineCount++;290 }291}292export function measureRichInlineStats(prepared, maxWidth) {293 const flow = getInternalPreparedRichInline(prepared);294 let lineCount = 0;295 let maxLineWidth = 0;296 const cursor = {297 itemIndex: 0,298 segmentIndex: 0,299 graphemeIndex: 0,300 };301 while (true) {302 const lineWidth = stepRichInlineLine(flow, maxWidth, cursor);303 if (lineWidth === null) {304 return {305 lineCount,306 maxLineWidth,307 };308 }309 lineCount++;310 if (lineWidth > maxLineWidth)311 maxLineWidth = lineWidth;312 }313}