1// Prepare text with Intl segmentation and cached Canvas measurements, then2// lay it out with arithmetic. Emoji calibration may perform a cached DOM read3// during preparation; layout itself does no measurement or string work.4// Rich APIs add source cursors, text materialization and approximate bidi metadata.5// Browser measurement limitations are documented in README.md and PLATFORM_BUGS.md.6// Based on Sebastian Markbage's text-layout research (github.com/chenglou/text-layout).7import { computeSegmentLevels } from './bidi.js';8import { analyzeText, clearAnalysisCaches, getBreakablePreferredBreaks, getCjkTextUnits, getSharedGraphemeSegmenter, isCJK, isNumericRunSegment, isIndependentSymbolRun, setAnalysisLocale, } from './analysis.js';9import { clearMeasurementCaches, getCorrectedSegmentWidth, getSegmentBreakableFitAdvances, getEngineProfile, getFontMeasurementState, getSegmentMetrics, textMayContainEmoji, } from './measurement.js';10import { countPreparedLines, measurePreparedLineGeometry, normalizePreparedLineStart, stepPreparedLineGeometryFromChunk, walkPreparedLinesRaw, } from './line-break.js';11import { buildLineTextFromRange, clearLineTextCaches, getLineTextCache, } from './line-text.js';12// --- Public API ---13function createEmptyPrepared(includeSegments) {14 if (includeSegments) {15 return {16 widths: [],17 lineEndFitAdvances: [],18 lineEndPaintAdvances: [],19 kinds: [],20 simpleLineWalkFastPath: true,21 segLevels: null,22 breakableFitAdvances: [],23 breakablePreferredBreaks: [],24 letterSpacing: 0,25 spacingGraphemeCounts: [],26 discretionaryHyphenWidth: 0,27 tabStopAdvance: 0,28 chunks: [],29 segments: [],30 };31 }32 return {33 widths: [],34 lineEndFitAdvances: [],35 lineEndPaintAdvances: [],36 kinds: [],37 simpleLineWalkFastPath: true,38 segLevels: null,39 breakableFitAdvances: [],40 breakablePreferredBreaks: [],41 letterSpacing: 0,42 spacingGraphemeCounts: [],43 discretionaryHyphenWidth: 0,44 tabStopAdvance: 0,45 chunks: [],46 };47}48function countRenderedSpacingGraphemes(text, kind) {49 if (kind === 'zero-width-break' ||50 kind === 'soft-hyphen' ||51 kind === 'hard-break') {52 return 0;53 }54 if (kind === 'tab')55 return 1;56 let count = 0;57 const graphemeSegmenter = getSharedGraphemeSegmenter();58 for (const _ of graphemeSegmenter.segment(text))59 count++;60 return count;61}62function addInternalLetterSpacing(width, graphemeCount, letterSpacing) {63 return graphemeCount > 1 ? width + (graphemeCount - 1) * letterSpacing : width;64}65function measureAnalysis(analysis, font, includeSegments, wordBreak, letterSpacing) {66 const engineProfile = getEngineProfile();67 const { cache, emojiCorrection } = getFontMeasurementState(font, textMayContainEmoji(analysis.normalized));68 const discretionaryHyphenWidth = getCorrectedSegmentWidth('-', getSegmentMetrics('-', cache), emojiCorrection) +69 (letterSpacing === 0 ? 0 : letterSpacing * 2);70 const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection);71 const tabStopAdvance = spaceWidth * 8;72 const hasLetterSpacing = letterSpacing !== 0;73 if (analysis.len === 0)74 return createEmptyPrepared(includeSegments);75 const widths = [];76 const lineEndFitAdvances = [];77 const lineEndPaintAdvances = [];78 const kinds = [];79 let simpleLineWalkFastPath = !hasLetterSpacing;80 const segStarts = includeSegments ? [] : null;81 const breakableFitAdvances = [];82 const breakablePreferredBreaks = [];83 const spacingGraphemeCounts = [];84 const segments = includeSegments ? [] : null;85 const chunks = [];86 let chunkStartSegmentIndex = 0;87 function pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, breakableFitAdvance, breakablePreferredBreak, spacingGraphemeCount) {88 if (kind !== 'text' && kind !== 'space' && kind !== 'zero-width-break') {89 simpleLineWalkFastPath = false;90 }91 widths.push(width);92 lineEndFitAdvances.push(lineEndFitAdvance);93 lineEndPaintAdvances.push(lineEndPaintAdvance);94 kinds.push(kind);95 segStarts?.push(start);96 breakableFitAdvances.push(breakableFitAdvance);97 breakablePreferredBreaks.push(breakablePreferredBreak);98 if (hasLetterSpacing)99 spacingGraphemeCounts.push(spacingGraphemeCount);100 if (segments !== null)101 segments.push(text);102 }103 function pushMeasuredTextSegment(text, textMetrics, kind, start, allowOverflowBreaks) {104 const spacingGraphemeCount = hasLetterSpacing105 ? countRenderedSpacingGraphemes(text, kind)106 : 0;107 const width = addInternalLetterSpacing(getCorrectedSegmentWidth(text, textMetrics, emojiCorrection), spacingGraphemeCount, letterSpacing);108 const baseLineEndFitAdvance = kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break'109 ? 0110 : width;111 const lineEndFitAdvance = baseLineEndFitAdvance === 0112 ? 0113 : baseLineEndFitAdvance + (spacingGraphemeCount > 0 ? letterSpacing : 0);114 const lineEndPaintAdvance = kind === 'space' || kind === 'zero-width-break'115 ? 0116 : width;117 if (allowOverflowBreaks && text.length > 1) {118 let fitMode = 'sum-graphemes';119 if (letterSpacing !== 0) {120 fitMode = 'segment-prefixes';121 }122 else if (isNumericRunSegment(text)) {123 fitMode = 'pair-context';124 }125 else if (engineProfile.preferPrefixWidthsForBreakableRuns) {126 fitMode = 'segment-prefixes';127 }128 const fitAdvances = getSegmentBreakableFitAdvances(text, textMetrics, cache, emojiCorrection, fitMode);129 const preferredBreaks = fitAdvances === null || wordBreak === 'keep-all'130 ? null131 : getBreakablePreferredBreaks(text);132 pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, fitAdvances, preferredBreaks, spacingGraphemeCount);133 return;134 }135 pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, null, null, spacingGraphemeCount);136 }137 for (let mi = 0; mi < analysis.len; mi++) {138 const segText = analysis.texts[mi];139 const segKind = analysis.kinds[mi];140 const segStart = analysis.starts[mi];141 if (segKind === 'soft-hyphen') {142 pushMeasuredSegment(segText, 0, discretionaryHyphenWidth, discretionaryHyphenWidth, segKind, segStart, null, null, 0);143 continue;144 }145 if (segKind === 'hard-break') {146 const endSegmentIndex = widths.length;147 pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null, 0);148 chunks.push({149 startSegmentIndex: chunkStartSegmentIndex,150 endSegmentIndex,151 consumedEndSegmentIndex: widths.length,152 });153 chunkStartSegmentIndex = widths.length;154 continue;155 }156 if (segKind === 'tab') {157 pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null, hasLetterSpacing ? countRenderedSpacingGraphemes(segText, segKind) : 0);158 continue;159 }160 // Measure CJK text only after its final line-break units are known.161 if (segKind === 'text' && isCJK(segText)) {162 const measuredUnits = getCjkTextUnits(segText, engineProfile, wordBreak);163 for (let i = 0; i < measuredUnits.length; i++) {164 const unit = measuredUnits[i];165 const unitMetrics = getSegmentMetrics(unit.text, cache);166 pushMeasuredTextSegment(unit.text, unitMetrics, 'text', segStart + unit.start, unit.overflow === 'grapheme' || (analysis.isWordLike[mi] && (wordBreak === 'keep-all' || unit.overflow === 'word-like')));167 }168 continue;169 }170 pushMeasuredTextSegment(segText, getSegmentMetrics(segText, cache), segKind, segStart, segKind === 'text' && (analysis.isWordLike[mi] || isIndependentSymbolRun(segText)));171 }172 if (chunkStartSegmentIndex < widths.length) {173 chunks.push({174 startSegmentIndex: chunkStartSegmentIndex,175 endSegmentIndex: widths.length,176 consumedEndSegmentIndex: widths.length,177 });178 }179 const segLevels = segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts);180 if (segments !== null) {181 return {182 widths,183 lineEndFitAdvances,184 lineEndPaintAdvances,185 kinds,186 simpleLineWalkFastPath,187 segLevels,188 breakableFitAdvances,189 breakablePreferredBreaks,190 letterSpacing,191 spacingGraphemeCounts,192 discretionaryHyphenWidth,193 tabStopAdvance,194 chunks,195 segments,196 };197 }198 return {199 widths,200 lineEndFitAdvances,201 lineEndPaintAdvances,202 kinds,203 simpleLineWalkFastPath,204 segLevels,205 breakableFitAdvances,206 breakablePreferredBreaks,207 letterSpacing,208 spacingGraphemeCounts,209 discretionaryHyphenWidth,210 tabStopAdvance,211 chunks,212 };213}214function prepareInternal(text, font, includeSegments, options) {215 const wordBreak = options?.wordBreak ?? 'normal';216 const letterSpacing = options?.letterSpacing ?? 0;217 const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace, wordBreak);218 return measureAnalysis(analysis, font, includeSegments, wordBreak, letterSpacing);219}220// Prepare text for layout. Segments the text, measures each segment via canvas,221// and stores the widths for fast relayout at any width. Call once per text block222// (e.g. when a comment first appears). The result is width-independent — the223// same PreparedText can be laid out at any maxWidth and lineHeight via layout().224//225// Steps:226// 1. Normalize collapsible whitespace (CSS white-space: normal behavior)227// 2. Segment via Intl.Segmenter (handles CJK, Thai, etc.)228// 3. Merge punctuation into preceding word ("better." as one unit)229// 4. Split CJK words into individual graphemes (per-character line breaks)230// 5. Measure each segment via canvas measureText, cache by (segment, font)231// 6. Pre-measure graphemes of long words (for overflow-wrap: break-word)232// 7. Correct emoji canvas inflation (auto-detected per font size)233// 8. Optionally compute rich-path bidi metadata for custom renderers234export function prepare(text, font, options) {235 return prepareInternal(text, font, false, options);236}237// Rich variant used by callers that need enough information to render the238// laid-out lines themselves.239export function prepareWithSegments(text, font, options) {240 return prepareInternal(text, font, true, options);241}242function getInternalPrepared(prepared) {243 return prepared;244}245// Layout prepared text at a given max width and caller-provided lineHeight.246// Pure arithmetic on cached widths — no canvas calls, no DOM reads, no string247// operations, and no per-line allocations.248// ~0.0002ms per text block. Call on every resize.249//250// Line breaking rules (matching CSS white-space: normal + overflow-wrap: break-word):251// - Break before any non-space segment that would overflow the line252// - Trailing whitespace hangs past the line edge (doesn't trigger breaks)253// - Segments wider than maxWidth are broken at grapheme boundaries254export function layout(prepared, maxWidth, lineHeight) {255 // Keep the resize hot path specialized. `layoutWithLines()` shares the same256 // break semantics but also tracks line ranges; the extra bookkeeping is too257 // expensive to pay on every hot-path `layout()` call.258 const lineCount = countPreparedLines(getInternalPrepared(prepared), maxWidth);259 return { lineCount, height: lineCount * lineHeight };260}261function createLayoutLine(prepared, cache, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {262 return {263 text: buildLineTextFromRange(prepared, cache, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex),264 width,265 start: {266 segmentIndex: startSegmentIndex,267 graphemeIndex: startGraphemeIndex,268 },269 end: {270 segmentIndex: endSegmentIndex,271 graphemeIndex: endGraphemeIndex,272 },273 };274}275function createLayoutLineRange(width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {276 return {277 width,278 start: {279 segmentIndex: startSegmentIndex,280 graphemeIndex: startGraphemeIndex,281 },282 end: {283 segmentIndex: endSegmentIndex,284 graphemeIndex: endGraphemeIndex,285 },286 };287}288export function materializeLineRange(prepared, line) {289 return createLayoutLine(prepared, getLineTextCache(prepared), line.width, line.start.segmentIndex, line.start.graphemeIndex, line.end.segmentIndex, line.end.graphemeIndex);290}291// Batch low-level line-range pass. This is the non-materializing counterpart292// to layoutWithLines(), useful for shrinkwrap and other aggregate stats work.293export function walkLineRanges(prepared, maxWidth, onLine) {294 if (prepared.widths.length === 0)295 return 0;296 return walkPreparedLinesRaw(getInternalPrepared(prepared), maxWidth, (width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) => {297 onLine(createLayoutLineRange(width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex));298 });299}300export function measureLineStats(prepared, maxWidth) {301 return measurePreparedLineGeometry(getInternalPrepared(prepared), maxWidth);302}303// Intrinsic-width helper for rich/userland layout work. This asks "how wide is304// the prepared text when container width is not the thing forcing wraps?".305// Explicit hard breaks still count, so this returns the widest forced line.306export function measureNaturalWidth(prepared) {307 let maxWidth = 0;308 walkPreparedLinesRaw(getInternalPrepared(prepared), Number.POSITIVE_INFINITY, width => {309 if (width > maxWidth)310 maxWidth = width;311 });312 return maxWidth;313}314export function layoutNextLine(prepared, start, maxWidth) {315 const internal = getInternalPrepared(prepared);316 const end = {317 segmentIndex: start.segmentIndex,318 graphemeIndex: start.graphemeIndex,319 };320 const chunkIndex = normalizePreparedLineStart(internal, end);321 if (chunkIndex < 0)322 return null;323 const lineStartSegmentIndex = end.segmentIndex;324 const lineStartGraphemeIndex = end.graphemeIndex;325 const width = stepPreparedLineGeometryFromChunk(internal, end, chunkIndex, maxWidth);326 if (width === null)327 return null;328 return createLayoutLine(prepared, getLineTextCache(prepared), width, lineStartSegmentIndex, lineStartGraphemeIndex, end.segmentIndex, end.graphemeIndex);329}330export function layoutNextLineRange(prepared, start, maxWidth) {331 const internal = getInternalPrepared(prepared);332 const end = {333 segmentIndex: start.segmentIndex,334 graphemeIndex: start.graphemeIndex,335 };336 const chunkIndex = normalizePreparedLineStart(internal, end);337 if (chunkIndex < 0)338 return null;339 const lineStartSegmentIndex = end.segmentIndex;340 const lineStartGraphemeIndex = end.graphemeIndex;341 const width = stepPreparedLineGeometryFromChunk(internal, end, chunkIndex, maxWidth);342 if (width === null)343 return null;344 return createLayoutLineRange(width, lineStartSegmentIndex, lineStartGraphemeIndex, end.segmentIndex, end.graphemeIndex);345}346// Rich layout API for callers that want the actual line contents and widths.347// Caller still supplies lineHeight at layout time. Mirrors layout()'s break348// decisions, but keeps extra per-line bookkeeping so it should stay off the349// resize hot path.350export function layoutWithLines(prepared, maxWidth, lineHeight) {351 const lines = [];352 if (prepared.widths.length === 0)353 return { lineCount: 0, height: 0, lines };354 const graphemeCache = getLineTextCache(prepared);355 const lineCount = walkPreparedLinesRaw(getInternalPrepared(prepared), maxWidth, (width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) => {356 lines.push(createLayoutLine(prepared, graphemeCache, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex));357 });358 return { lineCount, height: lineCount * lineHeight, lines };359}360export function clearCache() {361 clearAnalysisCaches();362 clearLineTextCaches();363 clearMeasurementCaches();364}365export function setLocale(locale) {366 setAnalysisLocale(locale);367 clearCache();368}