From fc1cffe0046764394eb4008e4e8474ad113bb13f Mon Sep 17 00:00:00 2001 From: okaris Date: Mon, 6 Apr 2026 12:54:55 +0200 Subject: [PATCH 1/2] feat: resolve rem/em font units to px before canvas measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas ctx.font silently ignores relative CSS units like rem and em (spec limitation — WHATWG HTML #1682). Callers passing CSS-derived font strings such as "0.9rem Inter" get no measurement at all; canvas keeps the previous font and returns wrong widths. This adds resolveFont() which rewrites relative units to absolute px using getComputedStyle(document.documentElement).fontSize before the string reaches canvas. Results are cached at module level so the DOM read happens once. Environments without a document (OffscreenCanvas, Node) fall back to 16px. - getFontMeasurementState() now resolves the font before setting ctx.font - parseFontSize() resolves before extracting the px value - clearMeasurementCaches() clears the resolution cache - resolveFont() is exported for direct use by consumers --- src/layout.test.ts | 35 ++++++++++++++++++++++++++++++++++- src/measurement.ts | 46 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/layout.test.ts b/src/layout.test.ts index d9b83327..7884cabf 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -12,6 +12,7 @@ type LayoutModule = typeof import('./layout.ts') type LineBreakModule = typeof import('./line-break.ts') type RichInlineModule = typeof import('./rich-inline.ts') type AnalysisModule = typeof import('./analysis.ts') +type MeasurementModule = typeof import('./measurement.ts') let prepare: LayoutModule['prepare'] let prepareWithSegments: LayoutModule['prepareWithSegments'] @@ -32,6 +33,8 @@ let materializeRichInlineLineRange: RichInlineModule['materializeRichInlineLineR let measureRichInlineStats: RichInlineModule['measureRichInlineStats'] let walkRichInlineLineRanges: RichInlineModule['walkRichInlineLineRanges'] let isCJK: AnalysisModule['isCJK'] +let resolveFont: MeasurementModule['resolveFont'] +let parseFontSizeReal: MeasurementModule['parseFontSize'] const emojiPresentationRe = /\p{Emoji_Presentation}/u const punctuationRe = /[.,!?;:%)\]}'"”’»›…—-]/u @@ -261,13 +264,15 @@ class TestOffscreenCanvas { beforeAll(async () => { Reflect.set(globalThis, 'OffscreenCanvas', TestOffscreenCanvas) - const [analysisMod, mod, lineBreakMod, richInlineMod] = await Promise.all([ + const [analysisMod, mod, lineBreakMod, richInlineMod, measurementMod] = await Promise.all([ import('./analysis.ts'), import('./layout.ts'), import('./line-break.ts'), import('./rich-inline.ts'), + import('./measurement.ts'), ]) ;({ isCJK } = analysisMod) + ;({ resolveFont, parseFontSize: parseFontSizeReal } = measurementMod) ;({ prepare, prepareWithSegments, @@ -1185,3 +1190,31 @@ describe('layout invariants', () => { } }) }) + +// --------------------------------------------------------------------------- +// Relative font unit resolution +// --------------------------------------------------------------------------- + +describe('resolveFont', () => { + test('px fonts pass through unchanged', () => { + expect(resolveFont('14px "Inter", sans-serif')).toBe('14px "Inter", sans-serif') + expect(resolveFont('bold 16px monospace')).toBe('bold 16px monospace') + }) + + // no document in test environment, so rem falls back to 16px root + test('rem resolved to px using root font size', () => { + expect(resolveFont('0.875rem "Inter", sans-serif')).toBe('14px "Inter", sans-serif') + expect(resolveFont('0.9rem "DM Sans", sans-serif')).toBe('14.4px "DM Sans", sans-serif') + expect(resolveFont('bold 1.5rem monospace')).toBe('bold 24px monospace') + }) + + test('em resolved to px using root font size', () => { + expect(resolveFont('1em "Inter"')).toBe('16px "Inter"') + expect(resolveFont('italic 0.8em serif')).toBe('italic 12.8px serif') + }) + + test('parseFontSize handles resolved rem', () => { + expect(parseFontSizeReal('0.875rem "Inter"')).toBe(14) + expect(parseFontSizeReal('0.9rem "DM Sans"')).toBe(14.4) + }) +}) diff --git a/src/measurement.ts b/src/measurement.ts index b2fb6d57..b0e26659 100644 --- a/src/measurement.ts +++ b/src/measurement.ts @@ -100,8 +100,41 @@ export function getEngineProfile(): EngineProfile { return cachedEngineProfile } +// Relative font unit resolution for canvas. +// +// Canvas ctx.font silently ignores rem/em (spec limitation — canvas can exist +// without a document root). Callers using CSS-derived font strings like +// "0.9rem Inter" will get the wrong measurement unless we resolve to px first. +// +// rem: resolved against document root font-size (one cached DOM read). +// em: treated as rem (no parent context available in canvas). +// Already-absolute fonts pass through unchanged with no allocation. + +let cachedRootFontSize: number | null = null +const resolvedFontCache = new Map() +const relativeFontRe = /(\d+(?:\.\d+)?)\s*(rem|em)/ + +function getRootFontSize(): number { + if (cachedRootFontSize !== null) return cachedRootFontSize + if (typeof document !== 'undefined' && document.documentElement) { + cachedRootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) + } + return cachedRootFontSize ?? 16 +} + +export function resolveFont(font: string): string { + let resolved = resolvedFontCache.get(font) + if (resolved !== undefined) return resolved + resolved = font.replace(relativeFontRe, (_, size) => { + return `${parseFloat(size) * getRootFontSize()}px` + }) + resolvedFontCache.set(font, resolved) + return resolved +} + export function parseFontSize(font: string): number { - const m = font.match(/(\d+(?:\.\d+)?)\s*px/) + const resolved = resolveFont(font) + const m = resolved.match(/(\d+(?:\.\d+)?)\s*px/) return m ? parseFloat(m[1]!) : 16 } @@ -216,16 +249,19 @@ export function getFontMeasurementState(font: string, needsEmojiCorrection: bool fontSize: number emojiCorrection: number } { + const resolved = resolveFont(font) const ctx = getMeasureContext() - ctx.font = font - const cache = getSegmentMetricCache(font) - const fontSize = parseFontSize(font) - const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(font, fontSize) : 0 + ctx.font = resolved + const cache = getSegmentMetricCache(resolved) + const fontSize = parseFontSize(resolved) + const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(resolved, fontSize) : 0 return { cache, fontSize, emojiCorrection } } export function clearMeasurementCaches(): void { segmentMetricCaches.clear() emojiCorrectionCache.clear() + resolvedFontCache.clear() + cachedRootFontSize = null sharedGraphemeSegmenter = null } From 66f926dc90c8185f534a58d18d0bba82d9ea74cf Mon Sep 17 00:00:00 2001 From: okaris Date: Mon, 6 Apr 2026 13:42:50 +0200 Subject: [PATCH 2/2] build: rebuild dist with relative font unit resolution --- dist/analysis.d.ts | 33 ++ dist/analysis.js | 975 +++++++++++++++++++++++++++++++++ dist/bidi.d.ts | 1 + dist/bidi.js | 175 ++++++ dist/generated/bidi-data.d.ts | 4 + dist/generated/bidi-data.js | 979 ++++++++++++++++++++++++++++++++++ dist/layout.d.ts | 71 +++ dist/layout.js | 454 ++++++++++++++++ dist/line-break.d.ts | 37 ++ dist/line-break.js | 848 +++++++++++++++++++++++++++++ dist/measurement.d.ts | 29 + dist/measurement.js | 210 ++++++++ dist/rich-inline.d.ts | 51 ++ dist/rich-inline.js | 401 ++++++++++++++ 14 files changed, 4268 insertions(+) create mode 100644 dist/analysis.d.ts create mode 100644 dist/analysis.js create mode 100644 dist/bidi.d.ts create mode 100644 dist/bidi.js create mode 100644 dist/generated/bidi-data.d.ts create mode 100644 dist/generated/bidi-data.js create mode 100644 dist/layout.d.ts create mode 100644 dist/layout.js create mode 100644 dist/line-break.d.ts create mode 100644 dist/line-break.js create mode 100644 dist/measurement.d.ts create mode 100644 dist/measurement.js create mode 100644 dist/rich-inline.d.ts create mode 100644 dist/rich-inline.js diff --git a/dist/analysis.d.ts b/dist/analysis.d.ts new file mode 100644 index 00000000..281ae759 --- /dev/null +++ b/dist/analysis.d.ts @@ -0,0 +1,33 @@ +export type WhiteSpaceMode = 'normal' | 'pre-wrap'; +export type WordBreakMode = 'normal' | 'keep-all'; +export type SegmentBreakKind = 'text' | 'space' | 'preserved-space' | 'tab' | 'glue' | 'zero-width-break' | 'soft-hyphen' | 'hard-break'; +export type MergedSegmentation = { + len: number; + texts: string[]; + isWordLike: boolean[]; + kinds: SegmentBreakKind[]; + starts: number[]; +}; +export type AnalysisChunk = { + startSegmentIndex: number; + endSegmentIndex: number; + consumedEndSegmentIndex: number; +}; +export type TextAnalysis = { + normalized: string; + chunks: AnalysisChunk[]; +} & MergedSegmentation; +export type AnalysisProfile = { + carryCJKAfterClosingQuote: boolean; +}; +export declare function normalizeWhitespaceNormal(text: string): string; +export declare function clearAnalysisCaches(): void; +export declare function setAnalysisLocale(locale?: string): void; +export declare function isCJK(s: string): boolean; +export declare function canContinueKeepAllTextRun(previousText: string): boolean; +export declare const kinsokuStart: Set; +export declare const kinsokuEnd: Set; +export declare const leftStickyPunctuation: Set; +export declare function endsWithClosingQuote(text: string): boolean; +export declare function isNumericRunSegment(text: string): boolean; +export declare function analyzeText(text: string, profile: AnalysisProfile, whiteSpace?: WhiteSpaceMode, wordBreak?: WordBreakMode): TextAnalysis; diff --git a/dist/analysis.js b/dist/analysis.js new file mode 100644 index 00000000..1a4a1493 --- /dev/null +++ b/dist/analysis.js @@ -0,0 +1,975 @@ +const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g; +const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/; +function getWhiteSpaceProfile(whiteSpace) { + const mode = whiteSpace ?? 'normal'; + return mode === 'pre-wrap' + ? { mode, preserveOrdinarySpaces: true, preserveHardBreaks: true } + : { mode, preserveOrdinarySpaces: false, preserveHardBreaks: false }; +} +export function normalizeWhitespaceNormal(text) { + if (!needsWhitespaceNormalizationRe.test(text)) + return text; + let normalized = text.replace(collapsibleWhitespaceRunRe, ' '); + if (normalized.charCodeAt(0) === 0x20) { + normalized = normalized.slice(1); + } + if (normalized.length > 0 && normalized.charCodeAt(normalized.length - 1) === 0x20) { + normalized = normalized.slice(0, -1); + } + return normalized; +} +function normalizeWhitespacePreWrap(text) { + if (!/[\r\f]/.test(text)) + return text.replace(/\r\n/g, '\n'); + return text + .replace(/\r\n/g, '\n') + .replace(/[\r\f]/g, '\n'); +} +let sharedWordSegmenter = null; +let segmenterLocale; +function getSharedWordSegmenter() { + if (sharedWordSegmenter === null) { + sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: 'word' }); + } + return sharedWordSegmenter; +} +export function clearAnalysisCaches() { + sharedWordSegmenter = null; +} +export function setAnalysisLocale(locale) { + const nextLocale = locale && locale.length > 0 ? locale : undefined; + if (segmenterLocale === nextLocale) + return; + segmenterLocale = nextLocale; + sharedWordSegmenter = null; +} +const arabicScriptRe = /\p{Script=Arabic}/u; +const combiningMarkRe = /\p{M}/u; +const decimalDigitRe = /\p{Nd}/u; +function containsArabicScript(text) { + return arabicScriptRe.test(text); +} +function isCJKCodePoint(codePoint) { + return ((codePoint >= 0x4E00 && codePoint <= 0x9FFF) || + (codePoint >= 0x3400 && codePoint <= 0x4DBF) || + (codePoint >= 0x20000 && codePoint <= 0x2A6DF) || + (codePoint >= 0x2A700 && codePoint <= 0x2B73F) || + (codePoint >= 0x2B740 && codePoint <= 0x2B81F) || + (codePoint >= 0x2B820 && codePoint <= 0x2CEAF) || + (codePoint >= 0x2CEB0 && codePoint <= 0x2EBEF) || + (codePoint >= 0x2EBF0 && codePoint <= 0x2EE5D) || + (codePoint >= 0x2F800 && codePoint <= 0x2FA1F) || + (codePoint >= 0x30000 && codePoint <= 0x3134F) || + (codePoint >= 0x31350 && codePoint <= 0x323AF) || + (codePoint >= 0x323B0 && codePoint <= 0x33479) || + (codePoint >= 0xF900 && codePoint <= 0xFAFF) || + (codePoint >= 0x3000 && codePoint <= 0x303F) || + (codePoint >= 0x3040 && codePoint <= 0x309F) || + (codePoint >= 0x30A0 && codePoint <= 0x30FF) || + (codePoint >= 0xAC00 && codePoint <= 0xD7AF) || + (codePoint >= 0xFF00 && codePoint <= 0xFFEF)); +} +export function isCJK(s) { + for (let i = 0; i < s.length; i++) { + const first = s.charCodeAt(i); + if (first < 0x3000) + continue; + if (first >= 0xD800 && first <= 0xDBFF && i + 1 < s.length) { + const second = s.charCodeAt(i + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { + const codePoint = ((first - 0xD800) << 10) + (second - 0xDC00) + 0x10000; + if (isCJKCodePoint(codePoint)) + return true; + i++; + continue; + } + } + if (isCJKCodePoint(first)) + return true; + } + return false; +} +function endsWithLineStartProhibitedText(text) { + let last = ''; + for (const ch of text) + last = ch; + return last.length > 0 && (kinsokuStart.has(last) || leftStickyPunctuation.has(last)); +} +const keepAllGlueChars = new Set([ + '\u00A0', + '\u202F', + '\u2060', + '\uFEFF', +]); +function containsCJKText(text) { + return isCJK(text); +} +function endsWithKeepAllGlueText(text) { + let last = ''; + for (const ch of text) + last = ch; + return last.length > 0 && keepAllGlueChars.has(last); +} +export function canContinueKeepAllTextRun(previousText) { + return (!endsWithLineStartProhibitedText(previousText) && + !endsWithKeepAllGlueText(previousText)); +} +export const kinsokuStart = new Set([ + '\uFF0C', + '\uFF0E', + '\uFF01', + '\uFF1A', + '\uFF1B', + '\uFF1F', + '\u3001', + '\u3002', + '\u30FB', + '\uFF09', + '\u3015', + '\u3009', + '\u300B', + '\u300D', + '\u300F', + '\u3011', + '\u3017', + '\u3019', + '\u301B', + '\u30FC', + '\u3005', + '\u303B', + '\u309D', + '\u309E', + '\u30FD', + '\u30FE', +]); +export const kinsokuEnd = new Set([ + '"', + '(', '[', '{', + '“', '‘', '«', '‹', + '\uFF08', + '\u3014', + '\u3008', + '\u300A', + '\u300C', + '\u300E', + '\u3010', + '\u3016', + '\u3018', + '\u301A', +]); +const forwardStickyGlue = new Set([ + "'", '’', +]); +export const leftStickyPunctuation = new Set([ + '.', ',', '!', '?', ':', ';', + '\u060C', + '\u061B', + '\u061F', + '\u0964', + '\u0965', + '\u104A', + '\u104B', + '\u104C', + '\u104D', + '\u104F', + ')', ']', '}', + '%', + '"', + '”', '’', '»', '›', + '…', +]); +const arabicNoSpaceTrailingPunctuation = new Set([ + ':', + '.', + '\u060C', + '\u061B', +]); +const myanmarMedialGlue = new Set([ + '\u104F', +]); +const closingQuoteChars = new Set([ + '”', '’', '»', '›', + '\u300D', + '\u300F', + '\u3011', + '\u300B', + '\u3009', + '\u3015', + '\uFF09', +]); +function isLeftStickyPunctuationSegment(segment) { + if (isEscapedQuoteClusterSegment(segment)) + return true; + let sawPunctuation = false; + for (const ch of segment) { + if (leftStickyPunctuation.has(ch)) { + sawPunctuation = true; + continue; + } + if (sawPunctuation && combiningMarkRe.test(ch)) + continue; + return false; + } + return sawPunctuation; +} +function isCJKLineStartProhibitedSegment(segment) { + for (const ch of segment) { + if (!kinsokuStart.has(ch) && !leftStickyPunctuation.has(ch)) + return false; + } + return segment.length > 0; +} +function isForwardStickyClusterSegment(segment) { + if (isEscapedQuoteClusterSegment(segment)) + return true; + for (const ch of segment) { + if (!kinsokuEnd.has(ch) && !forwardStickyGlue.has(ch) && !combiningMarkRe.test(ch)) + return false; + } + return segment.length > 0; +} +function isEscapedQuoteClusterSegment(segment) { + let sawQuote = false; + for (const ch of segment) { + if (ch === '\\' || combiningMarkRe.test(ch)) + continue; + if (kinsokuEnd.has(ch) || leftStickyPunctuation.has(ch) || forwardStickyGlue.has(ch)) { + sawQuote = true; + continue; + } + return false; + } + return sawQuote; +} +function previousCodePointStart(text, end) { + const last = end - 1; + if (last <= 0) + return Math.max(last, 0); + const lastCodeUnit = text.charCodeAt(last); + if (lastCodeUnit < 0xDC00 || lastCodeUnit > 0xDFFF) + return last; + const maybeHigh = last - 1; + if (maybeHigh < 0) + return last; + const highCodeUnit = text.charCodeAt(maybeHigh); + return highCodeUnit >= 0xD800 && highCodeUnit <= 0xDBFF ? maybeHigh : last; +} +function getLastCodePoint(text) { + if (text.length === 0) + return null; + const start = previousCodePointStart(text, text.length); + return text.slice(start); +} +function splitTrailingForwardStickyCluster(text) { + const chars = Array.from(text); + let splitIndex = chars.length; + while (splitIndex > 0) { + const ch = chars[splitIndex - 1]; + if (combiningMarkRe.test(ch)) { + splitIndex--; + continue; + } + if (kinsokuEnd.has(ch) || forwardStickyGlue.has(ch)) { + splitIndex--; + continue; + } + break; + } + if (splitIndex <= 0 || splitIndex === chars.length) + return null; + return { + head: chars.slice(0, splitIndex).join(''), + tail: chars.slice(splitIndex).join(''), + }; +} +function isRepeatedSingleCharRun(segment, ch) { + if (segment.length === 0) + return false; + for (const part of segment) { + if (part !== ch) + return false; + } + return true; +} +function endsWithArabicNoSpacePunctuation(segment) { + if (!containsArabicScript(segment) || segment.length === 0) + return false; + const lastCodePoint = getLastCodePoint(segment); + return lastCodePoint !== null && arabicNoSpaceTrailingPunctuation.has(lastCodePoint); +} +function endsWithMyanmarMedialGlue(segment) { + const lastCodePoint = getLastCodePoint(segment); + return lastCodePoint !== null && myanmarMedialGlue.has(lastCodePoint); +} +function splitLeadingSpaceAndMarks(segment) { + if (segment.length < 2 || segment[0] !== ' ') + return null; + const marks = segment.slice(1); + if (/^\p{M}+$/u.test(marks)) { + return { space: ' ', marks }; + } + return null; +} +export function endsWithClosingQuote(text) { + let end = text.length; + while (end > 0) { + const start = previousCodePointStart(text, end); + const ch = text.slice(start, end); + if (closingQuoteChars.has(ch)) + return true; + if (!leftStickyPunctuation.has(ch)) + return false; + end = start; + } + return false; +} +function classifySegmentBreakChar(ch, whiteSpaceProfile) { + if (whiteSpaceProfile.preserveOrdinarySpaces || whiteSpaceProfile.preserveHardBreaks) { + if (ch === ' ') + return 'preserved-space'; + if (ch === '\t') + return 'tab'; + if (whiteSpaceProfile.preserveHardBreaks && ch === '\n') + return 'hard-break'; + } + if (ch === ' ') + return 'space'; + if (ch === '\u00A0' || ch === '\u202F' || ch === '\u2060' || ch === '\uFEFF') { + return 'glue'; + } + if (ch === '\u200B') + return 'zero-width-break'; + if (ch === '\u00AD') + return 'soft-hyphen'; + return 'text'; +} +// All characters that classifySegmentBreakChar maps to a non-'text' kind. +const breakCharRe = /[\x20\t\n\xA0\xAD\u200B\u202F\u2060\uFEFF]/; +function joinTextParts(parts) { + return parts.length === 1 ? parts[0] : parts.join(''); +} +function splitSegmentByBreakKind(segment, isWordLike, start, whiteSpaceProfile) { + if (!breakCharRe.test(segment)) { + return [{ text: segment, isWordLike, kind: 'text', start }]; + } + const pieces = []; + let currentKind = null; + let currentTextParts = []; + let currentStart = start; + let currentWordLike = false; + let offset = 0; + for (const ch of segment) { + const kind = classifySegmentBreakChar(ch, whiteSpaceProfile); + const wordLike = kind === 'text' && isWordLike; + if (currentKind !== null && kind === currentKind && wordLike === currentWordLike) { + currentTextParts.push(ch); + offset += ch.length; + continue; + } + if (currentKind !== null) { + pieces.push({ + text: joinTextParts(currentTextParts), + isWordLike: currentWordLike, + kind: currentKind, + start: currentStart, + }); + } + currentKind = kind; + currentTextParts = [ch]; + currentStart = start + offset; + currentWordLike = wordLike; + offset += ch.length; + } + if (currentKind !== null) { + pieces.push({ + text: joinTextParts(currentTextParts), + isWordLike: currentWordLike, + kind: currentKind, + start: currentStart, + }); + } + return pieces; +} +function isTextRunBoundary(kind) { + return (kind === 'space' || + kind === 'preserved-space' || + kind === 'zero-width-break' || + kind === 'hard-break'); +} +const urlSchemeSegmentRe = /^[A-Za-z][A-Za-z0-9+.-]*:$/; +function isUrlLikeRunStart(segmentation, index) { + const text = segmentation.texts[index]; + if (text.startsWith('www.')) + return true; + return (urlSchemeSegmentRe.test(text) && + index + 1 < segmentation.len && + segmentation.kinds[index + 1] === 'text' && + segmentation.texts[index + 1] === '//'); +} +function isUrlQueryBoundarySegment(text) { + return text.includes('?') && (text.includes('://') || text.startsWith('www.')); +} +function mergeUrlLikeRuns(segmentation) { + const texts = segmentation.texts.slice(); + const isWordLike = segmentation.isWordLike.slice(); + const kinds = segmentation.kinds.slice(); + const starts = segmentation.starts.slice(); + for (let i = 0; i < segmentation.len; i++) { + if (kinds[i] !== 'text' || !isUrlLikeRunStart(segmentation, i)) + continue; + const mergedParts = [texts[i]]; + let j = i + 1; + while (j < segmentation.len && !isTextRunBoundary(kinds[j])) { + mergedParts.push(texts[j]); + isWordLike[i] = true; + const endsQueryPrefix = texts[j].includes('?'); + kinds[j] = 'text'; + texts[j] = ''; + j++; + if (endsQueryPrefix) + break; + } + texts[i] = joinTextParts(mergedParts); + } + let compactLen = 0; + for (let read = 0; read < texts.length; read++) { + const text = texts[read]; + if (text.length === 0) + continue; + if (compactLen !== read) { + texts[compactLen] = text; + isWordLike[compactLen] = isWordLike[read]; + kinds[compactLen] = kinds[read]; + starts[compactLen] = starts[read]; + } + compactLen++; + } + texts.length = compactLen; + isWordLike.length = compactLen; + kinds.length = compactLen; + starts.length = compactLen; + return { + len: compactLen, + texts, + isWordLike, + kinds, + starts, + }; +} +function mergeUrlQueryRuns(segmentation) { + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + for (let i = 0; i < segmentation.len; i++) { + const text = segmentation.texts[i]; + texts.push(text); + isWordLike.push(segmentation.isWordLike[i]); + kinds.push(segmentation.kinds[i]); + starts.push(segmentation.starts[i]); + if (!isUrlQueryBoundarySegment(text)) + continue; + const nextIndex = i + 1; + if (nextIndex >= segmentation.len || + isTextRunBoundary(segmentation.kinds[nextIndex])) { + continue; + } + const queryParts = []; + const queryStart = segmentation.starts[nextIndex]; + let j = nextIndex; + while (j < segmentation.len && !isTextRunBoundary(segmentation.kinds[j])) { + queryParts.push(segmentation.texts[j]); + j++; + } + if (queryParts.length > 0) { + texts.push(joinTextParts(queryParts)); + isWordLike.push(true); + kinds.push('text'); + starts.push(queryStart); + i = j - 1; + } + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +const numericJoinerChars = new Set([ + ':', '-', '/', '×', ',', '.', '+', + '\u2013', + '\u2014', +]); +const asciiPunctuationChainSegmentRe = /^[A-Za-z0-9_]+[,:;]*$/; +const asciiPunctuationChainTrailingJoinersRe = /[,:;]+$/; +function segmentContainsDecimalDigit(text) { + for (const ch of text) { + if (decimalDigitRe.test(ch)) + return true; + } + return false; +} +export function isNumericRunSegment(text) { + if (text.length === 0) + return false; + for (const ch of text) { + if (decimalDigitRe.test(ch) || numericJoinerChars.has(ch)) + continue; + return false; + } + return true; +} +function mergeNumericRuns(segmentation) { + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + for (let i = 0; i < segmentation.len; i++) { + const text = segmentation.texts[i]; + const kind = segmentation.kinds[i]; + if (kind === 'text' && isNumericRunSegment(text) && segmentContainsDecimalDigit(text)) { + const mergedParts = [text]; + let j = i + 1; + while (j < segmentation.len && + segmentation.kinds[j] === 'text' && + isNumericRunSegment(segmentation.texts[j])) { + mergedParts.push(segmentation.texts[j]); + j++; + } + texts.push(joinTextParts(mergedParts)); + isWordLike.push(true); + kinds.push('text'); + starts.push(segmentation.starts[i]); + i = j - 1; + continue; + } + texts.push(text); + isWordLike.push(segmentation.isWordLike[i]); + kinds.push(kind); + starts.push(segmentation.starts[i]); + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +function mergeAsciiPunctuationChains(segmentation) { + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + for (let i = 0; i < segmentation.len; i++) { + const text = segmentation.texts[i]; + const kind = segmentation.kinds[i]; + const wordLike = segmentation.isWordLike[i]; + if (kind === 'text' && wordLike && asciiPunctuationChainSegmentRe.test(text)) { + const mergedParts = [text]; + let endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(text); + let j = i + 1; + while (endsWithJoiners && + j < segmentation.len && + segmentation.kinds[j] === 'text' && + segmentation.isWordLike[j] && + asciiPunctuationChainSegmentRe.test(segmentation.texts[j])) { + const nextText = segmentation.texts[j]; + mergedParts.push(nextText); + endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(nextText); + j++; + } + texts.push(joinTextParts(mergedParts)); + isWordLike.push(true); + kinds.push('text'); + starts.push(segmentation.starts[i]); + i = j - 1; + continue; + } + texts.push(text); + isWordLike.push(wordLike); + kinds.push(kind); + starts.push(segmentation.starts[i]); + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +function splitHyphenatedNumericRuns(segmentation) { + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + for (let i = 0; i < segmentation.len; i++) { + const text = segmentation.texts[i]; + if (segmentation.kinds[i] === 'text' && text.includes('-')) { + const parts = text.split('-'); + let shouldSplit = parts.length > 1; + for (let j = 0; j < parts.length; j++) { + const part = parts[j]; + if (!shouldSplit) + break; + if (part.length === 0 || + !segmentContainsDecimalDigit(part) || + !isNumericRunSegment(part)) { + shouldSplit = false; + } + } + if (shouldSplit) { + let offset = 0; + for (let j = 0; j < parts.length; j++) { + const part = parts[j]; + const splitText = j < parts.length - 1 ? `${part}-` : part; + texts.push(splitText); + isWordLike.push(true); + kinds.push('text'); + starts.push(segmentation.starts[i] + offset); + offset += splitText.length; + } + continue; + } + } + texts.push(text); + isWordLike.push(segmentation.isWordLike[i]); + kinds.push(segmentation.kinds[i]); + starts.push(segmentation.starts[i]); + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +function mergeGlueConnectedTextRuns(segmentation) { + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + let read = 0; + while (read < segmentation.len) { + const textParts = [segmentation.texts[read]]; + let wordLike = segmentation.isWordLike[read]; + let kind = segmentation.kinds[read]; + let start = segmentation.starts[read]; + if (kind === 'glue') { + const glueParts = [textParts[0]]; + const glueStart = start; + read++; + while (read < segmentation.len && segmentation.kinds[read] === 'glue') { + glueParts.push(segmentation.texts[read]); + read++; + } + const glueText = joinTextParts(glueParts); + if (read < segmentation.len && segmentation.kinds[read] === 'text') { + textParts[0] = glueText; + textParts.push(segmentation.texts[read]); + wordLike = segmentation.isWordLike[read]; + kind = 'text'; + start = glueStart; + read++; + } + else { + texts.push(glueText); + isWordLike.push(false); + kinds.push('glue'); + starts.push(glueStart); + continue; + } + } + else { + read++; + } + if (kind === 'text') { + while (read < segmentation.len && segmentation.kinds[read] === 'glue') { + const glueParts = []; + while (read < segmentation.len && segmentation.kinds[read] === 'glue') { + glueParts.push(segmentation.texts[read]); + read++; + } + const glueText = joinTextParts(glueParts); + if (read < segmentation.len && segmentation.kinds[read] === 'text') { + textParts.push(glueText, segmentation.texts[read]); + wordLike = wordLike || segmentation.isWordLike[read]; + read++; + continue; + } + textParts.push(glueText); + } + } + texts.push(joinTextParts(textParts)); + isWordLike.push(wordLike); + kinds.push(kind); + starts.push(start); + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +function carryTrailingForwardStickyAcrossCJKBoundary(segmentation) { + const texts = segmentation.texts.slice(); + const isWordLike = segmentation.isWordLike.slice(); + const kinds = segmentation.kinds.slice(); + const starts = segmentation.starts.slice(); + for (let i = 0; i < texts.length - 1; i++) { + if (kinds[i] !== 'text' || kinds[i + 1] !== 'text') + continue; + if (!isCJK(texts[i]) || !isCJK(texts[i + 1])) + continue; + const split = splitTrailingForwardStickyCluster(texts[i]); + if (split === null) + continue; + texts[i] = split.head; + texts[i + 1] = split.tail + texts[i + 1]; + starts[i + 1] = starts[i] + split.head.length; + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +function buildMergedSegmentation(normalized, profile, whiteSpaceProfile) { + const wordSegmenter = getSharedWordSegmenter(); + let mergedLen = 0; + const mergedTexts = []; + const mergedWordLike = []; + const mergedKinds = []; + const mergedStarts = []; + for (const s of wordSegmenter.segment(normalized)) { + for (const piece of splitSegmentByBreakKind(s.segment, s.isWordLike ?? false, s.index, whiteSpaceProfile)) { + const isText = piece.kind === 'text'; + // First-pass keeps: no-space script-specific joins and punctuation glue + // that depend on the immediately preceding text run. + if (profile.carryCJKAfterClosingQuote && + isText && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + isCJK(piece.text) && + isCJK(mergedTexts[mergedLen - 1]) && + endsWithClosingQuote(mergedTexts[mergedLen - 1])) { + mergedTexts[mergedLen - 1] += piece.text; + mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike; + } + else if (isText && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + isCJKLineStartProhibitedSegment(piece.text) && + isCJK(mergedTexts[mergedLen - 1])) { + mergedTexts[mergedLen - 1] += piece.text; + mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike; + } + else if (isText && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + endsWithMyanmarMedialGlue(mergedTexts[mergedLen - 1])) { + mergedTexts[mergedLen - 1] += piece.text; + mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike; + } + else if (isText && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + piece.isWordLike && + containsArabicScript(piece.text) && + endsWithArabicNoSpacePunctuation(mergedTexts[mergedLen - 1])) { + mergedTexts[mergedLen - 1] += piece.text; + mergedWordLike[mergedLen - 1] = true; + } + else if (isText && + !piece.isWordLike && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + piece.text.length === 1 && + piece.text !== '-' && + piece.text !== '—' && + isRepeatedSingleCharRun(mergedTexts[mergedLen - 1], piece.text)) { + mergedTexts[mergedLen - 1] += piece.text; + } + else if (isText && + !piece.isWordLike && + mergedLen > 0 && + mergedKinds[mergedLen - 1] === 'text' && + (isLeftStickyPunctuationSegment(piece.text) || + (piece.text === '-' && mergedWordLike[mergedLen - 1]))) { + mergedTexts[mergedLen - 1] += piece.text; + } + else { + mergedTexts[mergedLen] = piece.text; + mergedWordLike[mergedLen] = piece.isWordLike; + mergedKinds[mergedLen] = piece.kind; + mergedStarts[mergedLen] = piece.start; + mergedLen++; + } + } + } + // Later passes operate on the merged text stream itself: contextual escaped + // quote glue, forward-sticky carry, compaction, then the broader URL/numeric + // and Arabic-leading-mark fixes. + for (let i = 1; i < mergedLen; i++) { + if (mergedKinds[i] === 'text' && + !mergedWordLike[i] && + isEscapedQuoteClusterSegment(mergedTexts[i]) && + mergedKinds[i - 1] === 'text') { + mergedTexts[i - 1] += mergedTexts[i]; + mergedWordLike[i - 1] = mergedWordLike[i - 1] || mergedWordLike[i]; + mergedTexts[i] = ''; + } + } + for (let i = mergedLen - 2; i >= 0; i--) { + if (mergedKinds[i] === 'text' && !mergedWordLike[i] && isForwardStickyClusterSegment(mergedTexts[i])) { + let j = i + 1; + while (j < mergedLen && mergedTexts[j] === '') + j++; + if (j < mergedLen && mergedKinds[j] === 'text') { + mergedTexts[j] = mergedTexts[i] + mergedTexts[j]; + mergedStarts[j] = mergedStarts[i]; + mergedTexts[i] = ''; + } + } + } + let compactLen = 0; + for (let read = 0; read < mergedLen; read++) { + const text = mergedTexts[read]; + if (text.length === 0) + continue; + if (compactLen !== read) { + mergedTexts[compactLen] = text; + mergedWordLike[compactLen] = mergedWordLike[read]; + mergedKinds[compactLen] = mergedKinds[read]; + mergedStarts[compactLen] = mergedStarts[read]; + } + compactLen++; + } + mergedTexts.length = compactLen; + mergedWordLike.length = compactLen; + mergedKinds.length = compactLen; + mergedStarts.length = compactLen; + const compacted = mergeGlueConnectedTextRuns({ + len: compactLen, + texts: mergedTexts, + isWordLike: mergedWordLike, + kinds: mergedKinds, + starts: mergedStarts, + }); + const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(mergeAsciiPunctuationChains(splitHyphenatedNumericRuns(mergeNumericRuns(mergeUrlQueryRuns(mergeUrlLikeRuns(compacted)))))); + for (let i = 0; i < withMergedUrls.len - 1; i++) { + const split = splitLeadingSpaceAndMarks(withMergedUrls.texts[i]); + if (split === null) + continue; + if ((withMergedUrls.kinds[i] !== 'space' && withMergedUrls.kinds[i] !== 'preserved-space') || + withMergedUrls.kinds[i + 1] !== 'text' || + !containsArabicScript(withMergedUrls.texts[i + 1])) { + continue; + } + withMergedUrls.texts[i] = split.space; + withMergedUrls.isWordLike[i] = false; + withMergedUrls.kinds[i] = withMergedUrls.kinds[i] === 'preserved-space' ? 'preserved-space' : 'space'; + withMergedUrls.texts[i + 1] = split.marks + withMergedUrls.texts[i + 1]; + withMergedUrls.starts[i + 1] = withMergedUrls.starts[i] + split.space.length; + } + return withMergedUrls; +} +function compileAnalysisChunks(segmentation, whiteSpaceProfile) { + if (segmentation.len === 0) + return []; + if (!whiteSpaceProfile.preserveHardBreaks) { + return [{ + startSegmentIndex: 0, + endSegmentIndex: segmentation.len, + consumedEndSegmentIndex: segmentation.len, + }]; + } + const chunks = []; + let startSegmentIndex = 0; + for (let i = 0; i < segmentation.len; i++) { + if (segmentation.kinds[i] !== 'hard-break') + continue; + chunks.push({ + startSegmentIndex, + endSegmentIndex: i, + consumedEndSegmentIndex: i + 1, + }); + startSegmentIndex = i + 1; + } + if (startSegmentIndex < segmentation.len) { + chunks.push({ + startSegmentIndex, + endSegmentIndex: segmentation.len, + consumedEndSegmentIndex: segmentation.len, + }); + } + return chunks; +} +function mergeKeepAllTextSegments(segmentation) { + if (segmentation.len <= 1) + return segmentation; + const texts = []; + const isWordLike = []; + const kinds = []; + const starts = []; + for (let i = 0; i < segmentation.len; i++) { + const text = segmentation.texts[i]; + const kind = segmentation.kinds[i]; + const wordLike = segmentation.isWordLike[i]; + const start = segmentation.starts[i]; + const previousIndex = texts.length - 1; + if (kind === 'text' && + previousIndex >= 0 && + kinds[previousIndex] === 'text' && + canContinueKeepAllTextRun(texts[previousIndex]) && + containsCJKText(texts[previousIndex])) { + texts[previousIndex] += text; + isWordLike[previousIndex] = isWordLike[previousIndex] || wordLike; + continue; + } + texts.push(text); + isWordLike.push(wordLike); + kinds.push(kind); + starts.push(start); + } + return { + len: texts.length, + texts, + isWordLike, + kinds, + starts, + }; +} +export function analyzeText(text, profile, whiteSpace = 'normal', wordBreak = 'normal') { + const whiteSpaceProfile = getWhiteSpaceProfile(whiteSpace); + const normalized = whiteSpaceProfile.mode === 'pre-wrap' + ? normalizeWhitespacePreWrap(text) + : normalizeWhitespaceNormal(text); + if (normalized.length === 0) { + return { + normalized, + chunks: [], + len: 0, + texts: [], + isWordLike: [], + kinds: [], + starts: [], + }; + } + const segmentation = wordBreak === 'keep-all' + ? mergeKeepAllTextSegments(buildMergedSegmentation(normalized, profile, whiteSpaceProfile)) + : buildMergedSegmentation(normalized, profile, whiteSpaceProfile); + return { + normalized, + chunks: compileAnalysisChunks(segmentation, whiteSpaceProfile), + ...segmentation, + }; +} diff --git a/dist/bidi.d.ts b/dist/bidi.d.ts new file mode 100644 index 00000000..312434ff --- /dev/null +++ b/dist/bidi.d.ts @@ -0,0 +1 @@ +export declare function computeSegmentLevels(normalized: string, segStarts: number[]): Int8Array | null; diff --git a/dist/bidi.js b/dist/bidi.js new file mode 100644 index 00000000..f1b9d191 --- /dev/null +++ b/dist/bidi.js @@ -0,0 +1,175 @@ +// Simplified bidi metadata helper for the rich prepareWithSegments() path, +// forked from pdf.js via Sebastian's text-layout. It classifies characters +// into bidi types, computes embedding levels, and maps them onto prepared +// segments for custom rendering. The line-breaking engine does not consume +// these levels. +import { latin1BidiTypes, nonLatin1BidiRanges, } from './generated/bidi-data.js'; +function classifyCodePoint(codePoint) { + if (codePoint <= 0x00FF) + return latin1BidiTypes[codePoint]; + let lo = 0; + let hi = nonLatin1BidiRanges.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const range = nonLatin1BidiRanges[mid]; + if (codePoint < range[0]) { + hi = mid - 1; + continue; + } + if (codePoint > range[1]) { + lo = mid + 1; + continue; + } + return range[2]; + } + return 'L'; +} +function computeBidiLevels(str) { + const len = str.length; + if (len === 0) + return null; + // eslint-disable-next-line unicorn/no-new-array + const types = new Array(len); + let sawBidi = false; + // Keep the resolved bidi classes aligned to UTF-16 code-unit offsets, + // because the rich prepared segments index back into the normalized string + // with JavaScript string offsets. + for (let i = 0; i < len;) { + const first = str.charCodeAt(i); + let codePoint = first; + let codeUnitLength = 1; + if (first >= 0xD800 && first <= 0xDBFF && i + 1 < len) { + const second = str.charCodeAt(i + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { + codePoint = ((first - 0xD800) << 10) + (second - 0xDC00) + 0x10000; + codeUnitLength = 2; + } + } + const t = classifyCodePoint(codePoint); + if (t === 'R' || t === 'AL' || t === 'AN') + sawBidi = true; + for (let j = 0; j < codeUnitLength; j++) { + types[i + j] = t; + } + i += codeUnitLength; + } + if (!sawBidi) + return null; + // Use the first strong character to pick the paragraph base direction. + // Rich-path bidi metadata is only an approximation, but this keeps mixed + // LTR/RTL text aligned with the common UBA paragraph rule. + let startLevel = 0; + for (let i = 0; i < len; i++) { + const t = types[i]; + if (t === 'L') { + startLevel = 0; + break; + } + if (t === 'R' || t === 'AL') { + startLevel = 1; + break; + } + } + const levels = new Int8Array(len); + for (let i = 0; i < len; i++) + levels[i] = startLevel; + const e = (startLevel & 1) ? 'R' : 'L'; + const sor = e; + // W1-W7 + let lastType = sor; + for (let i = 0; i < len; i++) { + if (types[i] === 'NSM') + types[i] = lastType; + else + lastType = types[i]; + } + lastType = sor; + for (let i = 0; i < len; i++) { + const t = types[i]; + if (t === 'EN') + types[i] = lastType === 'AL' ? 'AN' : 'EN'; + else if (t === 'R' || t === 'L' || t === 'AL') + lastType = t; + } + for (let i = 0; i < len; i++) { + if (types[i] === 'AL') + types[i] = 'R'; + } + for (let i = 1; i < len - 1; i++) { + if (types[i] === 'ES' && types[i - 1] === 'EN' && types[i + 1] === 'EN') { + types[i] = 'EN'; + } + if (types[i] === 'CS' && + (types[i - 1] === 'EN' || types[i - 1] === 'AN') && + types[i + 1] === types[i - 1]) { + types[i] = types[i - 1]; + } + } + for (let i = 0; i < len; i++) { + if (types[i] !== 'EN') + continue; + let j; + for (j = i - 1; j >= 0 && types[j] === 'ET'; j--) + types[j] = 'EN'; + for (j = i + 1; j < len && types[j] === 'ET'; j++) + types[j] = 'EN'; + } + for (let i = 0; i < len; i++) { + const t = types[i]; + if (t === 'WS' || t === 'ES' || t === 'ET' || t === 'CS') + types[i] = 'ON'; + } + lastType = sor; + for (let i = 0; i < len; i++) { + const t = types[i]; + if (t === 'EN') + types[i] = lastType === 'L' ? 'L' : 'EN'; + else if (t === 'R' || t === 'L') + lastType = t; + } + // N1-N2 + for (let i = 0; i < len; i++) { + if (types[i] !== 'ON') + continue; + let end = i + 1; + while (end < len && types[end] === 'ON') + end++; + const before = i > 0 ? types[i - 1] : sor; + const after = end < len ? types[end] : sor; + const bDir = before !== 'L' ? 'R' : 'L'; + const aDir = after !== 'L' ? 'R' : 'L'; + if (bDir === aDir) { + for (let j = i; j < end; j++) + types[j] = bDir; + } + i = end - 1; + } + for (let i = 0; i < len; i++) { + if (types[i] === 'ON') + types[i] = e; + } + // I1-I2 + for (let i = 0; i < len; i++) { + const t = types[i]; + if ((levels[i] & 1) === 0) { + if (t === 'R') + levels[i]++; + else if (t === 'AN' || t === 'EN') + levels[i] += 2; + } + else if (t === 'L' || t === 'AN' || t === 'EN') { + levels[i]++; + } + } + return levels; +} +export function computeSegmentLevels(normalized, segStarts) { + const bidiLevels = computeBidiLevels(normalized); + if (bidiLevels === null) + return null; + const segLevels = new Int8Array(segStarts.length); + for (let i = 0; i < segStarts.length; i++) { + segLevels[i] = bidiLevels[segStarts[i]]; + } + return segLevels; +} diff --git a/dist/generated/bidi-data.d.ts b/dist/generated/bidi-data.d.ts new file mode 100644 index 00000000..2a717788 --- /dev/null +++ b/dist/generated/bidi-data.d.ts @@ -0,0 +1,4 @@ +export type GeneratedBidiType = 'L' | 'R' | 'AL' | 'AN' | 'EN' | 'ES' | 'ET' | 'CS' | 'ON' | 'BN' | 'B' | 'S' | 'WS' | 'NSM'; +export declare const unicodeBidiDataVersion = "17.0.0"; +export declare const latin1BidiTypes: readonly GeneratedBidiType[]; +export declare const nonLatin1BidiRanges: readonly (readonly [number, number, GeneratedBidiType])[]; diff --git a/dist/generated/bidi-data.js b/dist/generated/bidi-data.js new file mode 100644 index 00000000..957987b4 --- /dev/null +++ b/dist/generated/bidi-data.js @@ -0,0 +1,979 @@ +// Generated by scripts/generate-bidi-data.ts from scripts/unicode/DerivedBidiClass-17.0.0.txt. +// Do not edit by hand. Regenerate with `bun run generate:bidi-data`. +// Formatting and isolate controls are projected onto `BN` because the rich +// bidi helper consumes a simplified class set and does not model UBA isolates. +export const unicodeBidiDataVersion = '17.0.0'; +export const latin1BidiTypes = [ + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'S', + 'B', + 'S', + 'WS', + 'B', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'B', + 'B', + 'B', + 'S', + 'WS', + 'ON', + 'ON', + 'ET', + 'ET', + 'ET', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'ES', + 'CS', + 'ES', + 'CS', + 'CS', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'EN', + 'CS', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'ON', + 'ON', + 'ON', + 'ON', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'B', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'BN', + 'CS', + 'ON', + 'ET', + 'ET', + 'ET', + 'ET', + 'ON', + 'ON', + 'ON', + 'ON', + 'L', + 'ON', + 'ON', + 'BN', + 'ON', + 'ON', + 'ET', + 'ET', + 'EN', + 'EN', + 'ON', + 'L', + 'ON', + 'ON', + 'ON', + 'EN', + 'L', + 'ON', + 'ON', + 'ON', + 'ON', + 'ON', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'ON', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'ON', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', + 'L', +]; +export const nonLatin1BidiRanges = [ + [0x2B9, 0x2BA, 'ON'], + [0x2C2, 0x2CF, 'ON'], + [0x2D2, 0x2DF, 'ON'], + [0x2E5, 0x2ED, 'ON'], + [0x2EF, 0x2FF, 'ON'], + [0x300, 0x36F, 'NSM'], + [0x374, 0x375, 'ON'], + [0x37E, 0x37E, 'ON'], + [0x384, 0x385, 'ON'], + [0x387, 0x387, 'ON'], + [0x3F6, 0x3F6, 'ON'], + [0x483, 0x489, 'NSM'], + [0x58A, 0x58A, 'ON'], + [0x58D, 0x58E, 'ON'], + [0x58F, 0x58F, 'ET'], + [0x590, 0x590, 'R'], + [0x591, 0x5BD, 'NSM'], + [0x5BE, 0x5BE, 'R'], + [0x5BF, 0x5BF, 'NSM'], + [0x5C0, 0x5C0, 'R'], + [0x5C1, 0x5C2, 'NSM'], + [0x5C3, 0x5C3, 'R'], + [0x5C4, 0x5C5, 'NSM'], + [0x5C6, 0x5C6, 'R'], + [0x5C7, 0x5C7, 'NSM'], + [0x5C8, 0x5FF, 'R'], + [0x600, 0x605, 'AN'], + [0x606, 0x607, 'ON'], + [0x608, 0x608, 'AL'], + [0x609, 0x60A, 'ET'], + [0x60B, 0x60B, 'AL'], + [0x60C, 0x60C, 'CS'], + [0x60D, 0x60D, 'AL'], + [0x60E, 0x60F, 'ON'], + [0x610, 0x61A, 'NSM'], + [0x61B, 0x64A, 'AL'], + [0x64B, 0x65F, 'NSM'], + [0x660, 0x669, 'AN'], + [0x66A, 0x66A, 'ET'], + [0x66B, 0x66C, 'AN'], + [0x66D, 0x66F, 'AL'], + [0x670, 0x670, 'NSM'], + [0x671, 0x6D5, 'AL'], + [0x6D6, 0x6DC, 'NSM'], + [0x6DD, 0x6DD, 'AN'], + [0x6DE, 0x6DE, 'ON'], + [0x6DF, 0x6E4, 'NSM'], + [0x6E5, 0x6E6, 'AL'], + [0x6E7, 0x6E8, 'NSM'], + [0x6E9, 0x6E9, 'ON'], + [0x6EA, 0x6ED, 'NSM'], + [0x6EE, 0x6EF, 'AL'], + [0x6F0, 0x6F9, 'EN'], + [0x6FA, 0x710, 'AL'], + [0x711, 0x711, 'NSM'], + [0x712, 0x72F, 'AL'], + [0x730, 0x74A, 'NSM'], + [0x74B, 0x7A5, 'AL'], + [0x7A6, 0x7B0, 'NSM'], + [0x7B1, 0x7BF, 'AL'], + [0x7C0, 0x7EA, 'R'], + [0x7EB, 0x7F3, 'NSM'], + [0x7F4, 0x7F5, 'R'], + [0x7F6, 0x7F9, 'ON'], + [0x7FA, 0x7FC, 'R'], + [0x7FD, 0x7FD, 'NSM'], + [0x7FE, 0x815, 'R'], + [0x816, 0x819, 'NSM'], + [0x81A, 0x81A, 'R'], + [0x81B, 0x823, 'NSM'], + [0x824, 0x824, 'R'], + [0x825, 0x827, 'NSM'], + [0x828, 0x828, 'R'], + [0x829, 0x82D, 'NSM'], + [0x82E, 0x858, 'R'], + [0x859, 0x85B, 'NSM'], + [0x85C, 0x85F, 'R'], + [0x860, 0x88F, 'AL'], + [0x890, 0x891, 'AN'], + [0x892, 0x896, 'AL'], + [0x897, 0x89F, 'NSM'], + [0x8A0, 0x8C9, 'AL'], + [0x8CA, 0x8E1, 'NSM'], + [0x8E2, 0x8E2, 'AN'], + [0x8E3, 0x902, 'NSM'], + [0x93A, 0x93A, 'NSM'], + [0x93C, 0x93C, 'NSM'], + [0x941, 0x948, 'NSM'], + [0x94D, 0x94D, 'NSM'], + [0x951, 0x957, 'NSM'], + [0x962, 0x963, 'NSM'], + [0x981, 0x981, 'NSM'], + [0x9BC, 0x9BC, 'NSM'], + [0x9C1, 0x9C4, 'NSM'], + [0x9CD, 0x9CD, 'NSM'], + [0x9E2, 0x9E3, 'NSM'], + [0x9F2, 0x9F3, 'ET'], + [0x9FB, 0x9FB, 'ET'], + [0x9FE, 0x9FE, 'NSM'], + [0xA01, 0xA02, 'NSM'], + [0xA3C, 0xA3C, 'NSM'], + [0xA41, 0xA42, 'NSM'], + [0xA47, 0xA48, 'NSM'], + [0xA4B, 0xA4D, 'NSM'], + [0xA51, 0xA51, 'NSM'], + [0xA70, 0xA71, 'NSM'], + [0xA75, 0xA75, 'NSM'], + [0xA81, 0xA82, 'NSM'], + [0xABC, 0xABC, 'NSM'], + [0xAC1, 0xAC5, 'NSM'], + [0xAC7, 0xAC8, 'NSM'], + [0xACD, 0xACD, 'NSM'], + [0xAE2, 0xAE3, 'NSM'], + [0xAF1, 0xAF1, 'ET'], + [0xAFA, 0xAFF, 'NSM'], + [0xB01, 0xB01, 'NSM'], + [0xB3C, 0xB3C, 'NSM'], + [0xB3F, 0xB3F, 'NSM'], + [0xB41, 0xB44, 'NSM'], + [0xB4D, 0xB4D, 'NSM'], + [0xB55, 0xB56, 'NSM'], + [0xB62, 0xB63, 'NSM'], + [0xB82, 0xB82, 'NSM'], + [0xBC0, 0xBC0, 'NSM'], + [0xBCD, 0xBCD, 'NSM'], + [0xBF3, 0xBF8, 'ON'], + [0xBF9, 0xBF9, 'ET'], + [0xBFA, 0xBFA, 'ON'], + [0xC00, 0xC00, 'NSM'], + [0xC04, 0xC04, 'NSM'], + [0xC3C, 0xC3C, 'NSM'], + [0xC3E, 0xC40, 'NSM'], + [0xC46, 0xC48, 'NSM'], + [0xC4A, 0xC4D, 'NSM'], + [0xC55, 0xC56, 'NSM'], + [0xC62, 0xC63, 'NSM'], + [0xC78, 0xC7E, 'ON'], + [0xC81, 0xC81, 'NSM'], + [0xCBC, 0xCBC, 'NSM'], + [0xCCC, 0xCCD, 'NSM'], + [0xCE2, 0xCE3, 'NSM'], + [0xD00, 0xD01, 'NSM'], + [0xD3B, 0xD3C, 'NSM'], + [0xD41, 0xD44, 'NSM'], + [0xD4D, 0xD4D, 'NSM'], + [0xD62, 0xD63, 'NSM'], + [0xD81, 0xD81, 'NSM'], + [0xDCA, 0xDCA, 'NSM'], + [0xDD2, 0xDD4, 'NSM'], + [0xDD6, 0xDD6, 'NSM'], + [0xE31, 0xE31, 'NSM'], + [0xE34, 0xE3A, 'NSM'], + [0xE3F, 0xE3F, 'ET'], + [0xE47, 0xE4E, 'NSM'], + [0xEB1, 0xEB1, 'NSM'], + [0xEB4, 0xEBC, 'NSM'], + [0xEC8, 0xECE, 'NSM'], + [0xF18, 0xF19, 'NSM'], + [0xF35, 0xF35, 'NSM'], + [0xF37, 0xF37, 'NSM'], + [0xF39, 0xF39, 'NSM'], + [0xF3A, 0xF3D, 'ON'], + [0xF71, 0xF7E, 'NSM'], + [0xF80, 0xF84, 'NSM'], + [0xF86, 0xF87, 'NSM'], + [0xF8D, 0xF97, 'NSM'], + [0xF99, 0xFBC, 'NSM'], + [0xFC6, 0xFC6, 'NSM'], + [0x102D, 0x1030, 'NSM'], + [0x1032, 0x1037, 'NSM'], + [0x1039, 0x103A, 'NSM'], + [0x103D, 0x103E, 'NSM'], + [0x1058, 0x1059, 'NSM'], + [0x105E, 0x1060, 'NSM'], + [0x1071, 0x1074, 'NSM'], + [0x1082, 0x1082, 'NSM'], + [0x1085, 0x1086, 'NSM'], + [0x108D, 0x108D, 'NSM'], + [0x109D, 0x109D, 'NSM'], + [0x135D, 0x135F, 'NSM'], + [0x1390, 0x1399, 'ON'], + [0x1400, 0x1400, 'ON'], + [0x1680, 0x1680, 'WS'], + [0x169B, 0x169C, 'ON'], + [0x1712, 0x1714, 'NSM'], + [0x1732, 0x1733, 'NSM'], + [0x1752, 0x1753, 'NSM'], + [0x1772, 0x1773, 'NSM'], + [0x17B4, 0x17B5, 'NSM'], + [0x17B7, 0x17BD, 'NSM'], + [0x17C6, 0x17C6, 'NSM'], + [0x17C9, 0x17D3, 'NSM'], + [0x17DB, 0x17DB, 'ET'], + [0x17DD, 0x17DD, 'NSM'], + [0x17F0, 0x17F9, 'ON'], + [0x1800, 0x180A, 'ON'], + [0x180B, 0x180D, 'NSM'], + [0x180E, 0x180E, 'BN'], + [0x180F, 0x180F, 'NSM'], + [0x1885, 0x1886, 'NSM'], + [0x18A9, 0x18A9, 'NSM'], + [0x1920, 0x1922, 'NSM'], + [0x1927, 0x1928, 'NSM'], + [0x1932, 0x1932, 'NSM'], + [0x1939, 0x193B, 'NSM'], + [0x1940, 0x1940, 'ON'], + [0x1944, 0x1945, 'ON'], + [0x19DE, 0x19FF, 'ON'], + [0x1A17, 0x1A18, 'NSM'], + [0x1A1B, 0x1A1B, 'NSM'], + [0x1A56, 0x1A56, 'NSM'], + [0x1A58, 0x1A5E, 'NSM'], + [0x1A60, 0x1A60, 'NSM'], + [0x1A62, 0x1A62, 'NSM'], + [0x1A65, 0x1A6C, 'NSM'], + [0x1A73, 0x1A7C, 'NSM'], + [0x1A7F, 0x1A7F, 'NSM'], + [0x1AB0, 0x1ADD, 'NSM'], + [0x1AE0, 0x1AEB, 'NSM'], + [0x1B00, 0x1B03, 'NSM'], + [0x1B34, 0x1B34, 'NSM'], + [0x1B36, 0x1B3A, 'NSM'], + [0x1B3C, 0x1B3C, 'NSM'], + [0x1B42, 0x1B42, 'NSM'], + [0x1B6B, 0x1B73, 'NSM'], + [0x1B80, 0x1B81, 'NSM'], + [0x1BA2, 0x1BA5, 'NSM'], + [0x1BA8, 0x1BA9, 'NSM'], + [0x1BAB, 0x1BAD, 'NSM'], + [0x1BE6, 0x1BE6, 'NSM'], + [0x1BE8, 0x1BE9, 'NSM'], + [0x1BED, 0x1BED, 'NSM'], + [0x1BEF, 0x1BF1, 'NSM'], + [0x1C2C, 0x1C33, 'NSM'], + [0x1C36, 0x1C37, 'NSM'], + [0x1CD0, 0x1CD2, 'NSM'], + [0x1CD4, 0x1CE0, 'NSM'], + [0x1CE2, 0x1CE8, 'NSM'], + [0x1CED, 0x1CED, 'NSM'], + [0x1CF4, 0x1CF4, 'NSM'], + [0x1CF8, 0x1CF9, 'NSM'], + [0x1DC0, 0x1DFF, 'NSM'], + [0x1FBD, 0x1FBD, 'ON'], + [0x1FBF, 0x1FC1, 'ON'], + [0x1FCD, 0x1FCF, 'ON'], + [0x1FDD, 0x1FDF, 'ON'], + [0x1FED, 0x1FEF, 'ON'], + [0x1FFD, 0x1FFE, 'ON'], + [0x2000, 0x200A, 'WS'], + [0x200B, 0x200D, 'BN'], + [0x200F, 0x200F, 'R'], + [0x2010, 0x2027, 'ON'], + [0x2028, 0x2028, 'WS'], + [0x2029, 0x2029, 'B'], + [0x202A, 0x202E, 'BN'], + [0x202F, 0x202F, 'CS'], + [0x2030, 0x2034, 'ET'], + [0x2035, 0x2043, 'ON'], + [0x2044, 0x2044, 'CS'], + [0x2045, 0x205E, 'ON'], + [0x205F, 0x205F, 'WS'], + [0x2060, 0x206F, 'BN'], + [0x2070, 0x2070, 'EN'], + [0x2074, 0x2079, 'EN'], + [0x207A, 0x207B, 'ES'], + [0x207C, 0x207E, 'ON'], + [0x2080, 0x2089, 'EN'], + [0x208A, 0x208B, 'ES'], + [0x208C, 0x208E, 'ON'], + [0x20A0, 0x20CF, 'ET'], + [0x20D0, 0x20F0, 'NSM'], + [0x2100, 0x2101, 'ON'], + [0x2103, 0x2106, 'ON'], + [0x2108, 0x2109, 'ON'], + [0x2114, 0x2114, 'ON'], + [0x2116, 0x2118, 'ON'], + [0x211E, 0x2123, 'ON'], + [0x2125, 0x2125, 'ON'], + [0x2127, 0x2127, 'ON'], + [0x2129, 0x2129, 'ON'], + [0x212E, 0x212E, 'ET'], + [0x213A, 0x213B, 'ON'], + [0x2140, 0x2144, 'ON'], + [0x214A, 0x214D, 'ON'], + [0x2150, 0x215F, 'ON'], + [0x2189, 0x218B, 'ON'], + [0x2190, 0x2211, 'ON'], + [0x2212, 0x2212, 'ES'], + [0x2213, 0x2213, 'ET'], + [0x2214, 0x2335, 'ON'], + [0x237B, 0x2394, 'ON'], + [0x2396, 0x2429, 'ON'], + [0x2440, 0x244A, 'ON'], + [0x2460, 0x2487, 'ON'], + [0x2488, 0x249B, 'EN'], + [0x24EA, 0x26AB, 'ON'], + [0x26AD, 0x27FF, 'ON'], + [0x2900, 0x2B73, 'ON'], + [0x2B76, 0x2BFF, 'ON'], + [0x2CE5, 0x2CEA, 'ON'], + [0x2CEF, 0x2CF1, 'NSM'], + [0x2CF9, 0x2CFF, 'ON'], + [0x2D7F, 0x2D7F, 'NSM'], + [0x2DE0, 0x2DFF, 'NSM'], + [0x2E00, 0x2E5D, 'ON'], + [0x2E80, 0x2E99, 'ON'], + [0x2E9B, 0x2EF3, 'ON'], + [0x2F00, 0x2FD5, 'ON'], + [0x2FF0, 0x2FFF, 'ON'], + [0x3000, 0x3000, 'WS'], + [0x3001, 0x3004, 'ON'], + [0x3008, 0x3020, 'ON'], + [0x302A, 0x302D, 'NSM'], + [0x3030, 0x3030, 'ON'], + [0x3036, 0x3037, 'ON'], + [0x303D, 0x303F, 'ON'], + [0x3099, 0x309A, 'NSM'], + [0x309B, 0x309C, 'ON'], + [0x30A0, 0x30A0, 'ON'], + [0x30FB, 0x30FB, 'ON'], + [0x31C0, 0x31E5, 'ON'], + [0x31EF, 0x31EF, 'ON'], + [0x321D, 0x321E, 'ON'], + [0x3250, 0x325F, 'ON'], + [0x327C, 0x327E, 'ON'], + [0x32B1, 0x32BF, 'ON'], + [0x32CC, 0x32CF, 'ON'], + [0x3377, 0x337A, 'ON'], + [0x33DE, 0x33DF, 'ON'], + [0x33FF, 0x33FF, 'ON'], + [0x4DC0, 0x4DFF, 'ON'], + [0xA490, 0xA4C6, 'ON'], + [0xA60D, 0xA60F, 'ON'], + [0xA66F, 0xA672, 'NSM'], + [0xA673, 0xA673, 'ON'], + [0xA674, 0xA67D, 'NSM'], + [0xA67E, 0xA67F, 'ON'], + [0xA69E, 0xA69F, 'NSM'], + [0xA6F0, 0xA6F1, 'NSM'], + [0xA700, 0xA721, 'ON'], + [0xA788, 0xA788, 'ON'], + [0xA802, 0xA802, 'NSM'], + [0xA806, 0xA806, 'NSM'], + [0xA80B, 0xA80B, 'NSM'], + [0xA825, 0xA826, 'NSM'], + [0xA828, 0xA82B, 'ON'], + [0xA82C, 0xA82C, 'NSM'], + [0xA838, 0xA839, 'ET'], + [0xA874, 0xA877, 'ON'], + [0xA8C4, 0xA8C5, 'NSM'], + [0xA8E0, 0xA8F1, 'NSM'], + [0xA8FF, 0xA8FF, 'NSM'], + [0xA926, 0xA92D, 'NSM'], + [0xA947, 0xA951, 'NSM'], + [0xA980, 0xA982, 'NSM'], + [0xA9B3, 0xA9B3, 'NSM'], + [0xA9B6, 0xA9B9, 'NSM'], + [0xA9BC, 0xA9BD, 'NSM'], + [0xA9E5, 0xA9E5, 'NSM'], + [0xAA29, 0xAA2E, 'NSM'], + [0xAA31, 0xAA32, 'NSM'], + [0xAA35, 0xAA36, 'NSM'], + [0xAA43, 0xAA43, 'NSM'], + [0xAA4C, 0xAA4C, 'NSM'], + [0xAA7C, 0xAA7C, 'NSM'], + [0xAAB0, 0xAAB0, 'NSM'], + [0xAAB2, 0xAAB4, 'NSM'], + [0xAAB7, 0xAAB8, 'NSM'], + [0xAABE, 0xAABF, 'NSM'], + [0xAAC1, 0xAAC1, 'NSM'], + [0xAAEC, 0xAAED, 'NSM'], + [0xAAF6, 0xAAF6, 'NSM'], + [0xAB6A, 0xAB6B, 'ON'], + [0xABE5, 0xABE5, 'NSM'], + [0xABE8, 0xABE8, 'NSM'], + [0xABED, 0xABED, 'NSM'], + [0xFB1D, 0xFB1D, 'R'], + [0xFB1E, 0xFB1E, 'NSM'], + [0xFB1F, 0xFB28, 'R'], + [0xFB29, 0xFB29, 'ES'], + [0xFB2A, 0xFB4F, 'R'], + [0xFB50, 0xFBC2, 'AL'], + [0xFBC3, 0xFBD2, 'ON'], + [0xFBD3, 0xFD3D, 'AL'], + [0xFD3E, 0xFD4F, 'ON'], + [0xFD50, 0xFD8F, 'AL'], + [0xFD90, 0xFD91, 'ON'], + [0xFD92, 0xFDC7, 'AL'], + [0xFDC8, 0xFDCF, 'ON'], + [0xFDD0, 0xFDEF, 'BN'], + [0xFDF0, 0xFDFC, 'AL'], + [0xFDFD, 0xFDFF, 'ON'], + [0xFE00, 0xFE0F, 'NSM'], + [0xFE10, 0xFE19, 'ON'], + [0xFE20, 0xFE2F, 'NSM'], + [0xFE30, 0xFE4F, 'ON'], + [0xFE50, 0xFE50, 'CS'], + [0xFE51, 0xFE51, 'ON'], + [0xFE52, 0xFE52, 'CS'], + [0xFE54, 0xFE54, 'ON'], + [0xFE55, 0xFE55, 'CS'], + [0xFE56, 0xFE5E, 'ON'], + [0xFE5F, 0xFE5F, 'ET'], + [0xFE60, 0xFE61, 'ON'], + [0xFE62, 0xFE63, 'ES'], + [0xFE64, 0xFE66, 'ON'], + [0xFE68, 0xFE68, 'ON'], + [0xFE69, 0xFE6A, 'ET'], + [0xFE6B, 0xFE6B, 'ON'], + [0xFE70, 0xFEFE, 'AL'], + [0xFEFF, 0xFEFF, 'BN'], + [0xFF01, 0xFF02, 'ON'], + [0xFF03, 0xFF05, 'ET'], + [0xFF06, 0xFF0A, 'ON'], + [0xFF0B, 0xFF0B, 'ES'], + [0xFF0C, 0xFF0C, 'CS'], + [0xFF0D, 0xFF0D, 'ES'], + [0xFF0E, 0xFF0F, 'CS'], + [0xFF10, 0xFF19, 'EN'], + [0xFF1A, 0xFF1A, 'CS'], + [0xFF1B, 0xFF20, 'ON'], + [0xFF3B, 0xFF40, 'ON'], + [0xFF5B, 0xFF65, 'ON'], + [0xFFE0, 0xFFE1, 'ET'], + [0xFFE2, 0xFFE4, 'ON'], + [0xFFE5, 0xFFE6, 'ET'], + [0xFFE8, 0xFFEE, 'ON'], + [0xFFF0, 0xFFF8, 'BN'], + [0xFFF9, 0xFFFD, 'ON'], + [0xFFFE, 0xFFFF, 'BN'], + [0x10101, 0x10101, 'ON'], + [0x10140, 0x1018C, 'ON'], + [0x10190, 0x1019C, 'ON'], + [0x101A0, 0x101A0, 'ON'], + [0x101FD, 0x101FD, 'NSM'], + [0x102E0, 0x102E0, 'NSM'], + [0x102E1, 0x102FB, 'EN'], + [0x10376, 0x1037A, 'NSM'], + [0x10800, 0x1091E, 'R'], + [0x1091F, 0x1091F, 'ON'], + [0x10920, 0x10A00, 'R'], + [0x10A01, 0x10A03, 'NSM'], + [0x10A04, 0x10A04, 'R'], + [0x10A05, 0x10A06, 'NSM'], + [0x10A07, 0x10A0B, 'R'], + [0x10A0C, 0x10A0F, 'NSM'], + [0x10A10, 0x10A37, 'R'], + [0x10A38, 0x10A3A, 'NSM'], + [0x10A3B, 0x10A3E, 'R'], + [0x10A3F, 0x10A3F, 'NSM'], + [0x10A40, 0x10AE4, 'R'], + [0x10AE5, 0x10AE6, 'NSM'], + [0x10AE7, 0x10B38, 'R'], + [0x10B39, 0x10B3F, 'ON'], + [0x10B40, 0x10CFF, 'R'], + [0x10D00, 0x10D23, 'AL'], + [0x10D24, 0x10D27, 'NSM'], + [0x10D28, 0x10D2F, 'AL'], + [0x10D30, 0x10D39, 'AN'], + [0x10D3A, 0x10D3F, 'AL'], + [0x10D40, 0x10D49, 'AN'], + [0x10D4A, 0x10D68, 'R'], + [0x10D69, 0x10D6D, 'NSM'], + [0x10D6E, 0x10D6E, 'ON'], + [0x10D6F, 0x10E5F, 'R'], + [0x10E60, 0x10E7E, 'AN'], + [0x10E7F, 0x10EAA, 'R'], + [0x10EAB, 0x10EAC, 'NSM'], + [0x10EAD, 0x10EBF, 'R'], + [0x10EC0, 0x10ECF, 'AL'], + [0x10ED0, 0x10ED8, 'ON'], + [0x10ED9, 0x10EF9, 'AL'], + [0x10EFA, 0x10EFF, 'NSM'], + [0x10F00, 0x10F2F, 'R'], + [0x10F30, 0x10F45, 'AL'], + [0x10F46, 0x10F50, 'NSM'], + [0x10F51, 0x10F6F, 'AL'], + [0x10F70, 0x10F81, 'R'], + [0x10F82, 0x10F85, 'NSM'], + [0x10F86, 0x10FFF, 'R'], + [0x11001, 0x11001, 'NSM'], + [0x11038, 0x11046, 'NSM'], + [0x11052, 0x11065, 'ON'], + [0x11070, 0x11070, 'NSM'], + [0x11073, 0x11074, 'NSM'], + [0x1107F, 0x11081, 'NSM'], + [0x110B3, 0x110B6, 'NSM'], + [0x110B9, 0x110BA, 'NSM'], + [0x110C2, 0x110C2, 'NSM'], + [0x11100, 0x11102, 'NSM'], + [0x11127, 0x1112B, 'NSM'], + [0x1112D, 0x11134, 'NSM'], + [0x11173, 0x11173, 'NSM'], + [0x11180, 0x11181, 'NSM'], + [0x111B6, 0x111BE, 'NSM'], + [0x111C9, 0x111CC, 'NSM'], + [0x111CF, 0x111CF, 'NSM'], + [0x1122F, 0x11231, 'NSM'], + [0x11234, 0x11234, 'NSM'], + [0x11236, 0x11237, 'NSM'], + [0x1123E, 0x1123E, 'NSM'], + [0x11241, 0x11241, 'NSM'], + [0x112DF, 0x112DF, 'NSM'], + [0x112E3, 0x112EA, 'NSM'], + [0x11300, 0x11301, 'NSM'], + [0x1133B, 0x1133C, 'NSM'], + [0x11340, 0x11340, 'NSM'], + [0x11366, 0x1136C, 'NSM'], + [0x11370, 0x11374, 'NSM'], + [0x113BB, 0x113C0, 'NSM'], + [0x113CE, 0x113CE, 'NSM'], + [0x113D0, 0x113D0, 'NSM'], + [0x113D2, 0x113D2, 'NSM'], + [0x113E1, 0x113E2, 'NSM'], + [0x11438, 0x1143F, 'NSM'], + [0x11442, 0x11444, 'NSM'], + [0x11446, 0x11446, 'NSM'], + [0x1145E, 0x1145E, 'NSM'], + [0x114B3, 0x114B8, 'NSM'], + [0x114BA, 0x114BA, 'NSM'], + [0x114BF, 0x114C0, 'NSM'], + [0x114C2, 0x114C3, 'NSM'], + [0x115B2, 0x115B5, 'NSM'], + [0x115BC, 0x115BD, 'NSM'], + [0x115BF, 0x115C0, 'NSM'], + [0x115DC, 0x115DD, 'NSM'], + [0x11633, 0x1163A, 'NSM'], + [0x1163D, 0x1163D, 'NSM'], + [0x1163F, 0x11640, 'NSM'], + [0x11660, 0x1166C, 'ON'], + [0x116AB, 0x116AB, 'NSM'], + [0x116AD, 0x116AD, 'NSM'], + [0x116B0, 0x116B5, 'NSM'], + [0x116B7, 0x116B7, 'NSM'], + [0x1171D, 0x1171D, 'NSM'], + [0x1171F, 0x1171F, 'NSM'], + [0x11722, 0x11725, 'NSM'], + [0x11727, 0x1172B, 'NSM'], + [0x1182F, 0x11837, 'NSM'], + [0x11839, 0x1183A, 'NSM'], + [0x1193B, 0x1193C, 'NSM'], + [0x1193E, 0x1193E, 'NSM'], + [0x11943, 0x11943, 'NSM'], + [0x119D4, 0x119D7, 'NSM'], + [0x119DA, 0x119DB, 'NSM'], + [0x119E0, 0x119E0, 'NSM'], + [0x11A01, 0x11A06, 'NSM'], + [0x11A09, 0x11A0A, 'NSM'], + [0x11A33, 0x11A38, 'NSM'], + [0x11A3B, 0x11A3E, 'NSM'], + [0x11A47, 0x11A47, 'NSM'], + [0x11A51, 0x11A56, 'NSM'], + [0x11A59, 0x11A5B, 'NSM'], + [0x11A8A, 0x11A96, 'NSM'], + [0x11A98, 0x11A99, 'NSM'], + [0x11B60, 0x11B60, 'NSM'], + [0x11B62, 0x11B64, 'NSM'], + [0x11B66, 0x11B66, 'NSM'], + [0x11C30, 0x11C36, 'NSM'], + [0x11C38, 0x11C3D, 'NSM'], + [0x11C92, 0x11CA7, 'NSM'], + [0x11CAA, 0x11CB0, 'NSM'], + [0x11CB2, 0x11CB3, 'NSM'], + [0x11CB5, 0x11CB6, 'NSM'], + [0x11D31, 0x11D36, 'NSM'], + [0x11D3A, 0x11D3A, 'NSM'], + [0x11D3C, 0x11D3D, 'NSM'], + [0x11D3F, 0x11D45, 'NSM'], + [0x11D47, 0x11D47, 'NSM'], + [0x11D90, 0x11D91, 'NSM'], + [0x11D95, 0x11D95, 'NSM'], + [0x11D97, 0x11D97, 'NSM'], + [0x11EF3, 0x11EF4, 'NSM'], + [0x11F00, 0x11F01, 'NSM'], + [0x11F36, 0x11F3A, 'NSM'], + [0x11F40, 0x11F40, 'NSM'], + [0x11F42, 0x11F42, 'NSM'], + [0x11F5A, 0x11F5A, 'NSM'], + [0x11FD5, 0x11FDC, 'ON'], + [0x11FDD, 0x11FE0, 'ET'], + [0x11FE1, 0x11FF1, 'ON'], + [0x13440, 0x13440, 'NSM'], + [0x13447, 0x13455, 'NSM'], + [0x1611E, 0x16129, 'NSM'], + [0x1612D, 0x1612F, 'NSM'], + [0x16AF0, 0x16AF4, 'NSM'], + [0x16B30, 0x16B36, 'NSM'], + [0x16F4F, 0x16F4F, 'NSM'], + [0x16F8F, 0x16F92, 'NSM'], + [0x16FE2, 0x16FE2, 'ON'], + [0x16FE4, 0x16FE4, 'NSM'], + [0x1BC9D, 0x1BC9E, 'NSM'], + [0x1BCA0, 0x1BCA3, 'BN'], + [0x1CC00, 0x1CCD5, 'ON'], + [0x1CCF0, 0x1CCF9, 'EN'], + [0x1CCFA, 0x1CCFC, 'ON'], + [0x1CD00, 0x1CEB3, 'ON'], + [0x1CEBA, 0x1CED0, 'ON'], + [0x1CEE0, 0x1CEF0, 'ON'], + [0x1CF00, 0x1CF2D, 'NSM'], + [0x1CF30, 0x1CF46, 'NSM'], + [0x1D167, 0x1D169, 'NSM'], + [0x1D173, 0x1D17A, 'BN'], + [0x1D17B, 0x1D182, 'NSM'], + [0x1D185, 0x1D18B, 'NSM'], + [0x1D1AA, 0x1D1AD, 'NSM'], + [0x1D1E9, 0x1D1EA, 'ON'], + [0x1D200, 0x1D241, 'ON'], + [0x1D242, 0x1D244, 'NSM'], + [0x1D245, 0x1D245, 'ON'], + [0x1D300, 0x1D356, 'ON'], + [0x1D6C1, 0x1D6C1, 'ON'], + [0x1D6DB, 0x1D6DB, 'ON'], + [0x1D6FB, 0x1D6FB, 'ON'], + [0x1D715, 0x1D715, 'ON'], + [0x1D735, 0x1D735, 'ON'], + [0x1D74F, 0x1D74F, 'ON'], + [0x1D76F, 0x1D76F, 'ON'], + [0x1D789, 0x1D789, 'ON'], + [0x1D7A9, 0x1D7A9, 'ON'], + [0x1D7C3, 0x1D7C3, 'ON'], + [0x1D7CE, 0x1D7FF, 'EN'], + [0x1DA00, 0x1DA36, 'NSM'], + [0x1DA3B, 0x1DA6C, 'NSM'], + [0x1DA75, 0x1DA75, 'NSM'], + [0x1DA84, 0x1DA84, 'NSM'], + [0x1DA9B, 0x1DA9F, 'NSM'], + [0x1DAA1, 0x1DAAF, 'NSM'], + [0x1E000, 0x1E006, 'NSM'], + [0x1E008, 0x1E018, 'NSM'], + [0x1E01B, 0x1E021, 'NSM'], + [0x1E023, 0x1E024, 'NSM'], + [0x1E026, 0x1E02A, 'NSM'], + [0x1E08F, 0x1E08F, 'NSM'], + [0x1E130, 0x1E136, 'NSM'], + [0x1E2AE, 0x1E2AE, 'NSM'], + [0x1E2EC, 0x1E2EF, 'NSM'], + [0x1E2FF, 0x1E2FF, 'ET'], + [0x1E4EC, 0x1E4EF, 'NSM'], + [0x1E5EE, 0x1E5EF, 'NSM'], + [0x1E6E3, 0x1E6E3, 'NSM'], + [0x1E6E6, 0x1E6E6, 'NSM'], + [0x1E6EE, 0x1E6EF, 'NSM'], + [0x1E6F5, 0x1E6F5, 'NSM'], + [0x1E800, 0x1E8CF, 'R'], + [0x1E8D0, 0x1E8D6, 'NSM'], + [0x1E8D7, 0x1E943, 'R'], + [0x1E944, 0x1E94A, 'NSM'], + [0x1E94B, 0x1EC6F, 'R'], + [0x1EC70, 0x1ECBF, 'AL'], + [0x1ECC0, 0x1ECFF, 'R'], + [0x1ED00, 0x1ED4F, 'AL'], + [0x1ED50, 0x1EDFF, 'R'], + [0x1EE00, 0x1EEEF, 'AL'], + [0x1EEF0, 0x1EEF1, 'ON'], + [0x1EEF2, 0x1EEFF, 'AL'], + [0x1EF00, 0x1EFFF, 'R'], + [0x1F000, 0x1F02B, 'ON'], + [0x1F030, 0x1F093, 'ON'], + [0x1F0A0, 0x1F0AE, 'ON'], + [0x1F0B1, 0x1F0BF, 'ON'], + [0x1F0C1, 0x1F0CF, 'ON'], + [0x1F0D1, 0x1F0F5, 'ON'], + [0x1F100, 0x1F10A, 'EN'], + [0x1F10B, 0x1F10F, 'ON'], + [0x1F12F, 0x1F12F, 'ON'], + [0x1F16A, 0x1F16F, 'ON'], + [0x1F1AD, 0x1F1AD, 'ON'], + [0x1F260, 0x1F265, 'ON'], + [0x1F300, 0x1F6D8, 'ON'], + [0x1F6DC, 0x1F6EC, 'ON'], + [0x1F6F0, 0x1F6FC, 'ON'], + [0x1F700, 0x1F7D9, 'ON'], + [0x1F7E0, 0x1F7EB, 'ON'], + [0x1F7F0, 0x1F7F0, 'ON'], + [0x1F800, 0x1F80B, 'ON'], + [0x1F810, 0x1F847, 'ON'], + [0x1F850, 0x1F859, 'ON'], + [0x1F860, 0x1F887, 'ON'], + [0x1F890, 0x1F8AD, 'ON'], + [0x1F8B0, 0x1F8BB, 'ON'], + [0x1F8C0, 0x1F8C1, 'ON'], + [0x1F8D0, 0x1F8D8, 'ON'], + [0x1F900, 0x1FA57, 'ON'], + [0x1FA60, 0x1FA6D, 'ON'], + [0x1FA70, 0x1FA7C, 'ON'], + [0x1FA80, 0x1FA8A, 'ON'], + [0x1FA8E, 0x1FAC6, 'ON'], + [0x1FAC8, 0x1FAC8, 'ON'], + [0x1FACD, 0x1FADC, 'ON'], + [0x1FADF, 0x1FAEA, 'ON'], + [0x1FAEF, 0x1FAF8, 'ON'], + [0x1FB00, 0x1FB92, 'ON'], + [0x1FB94, 0x1FBEF, 'ON'], + [0x1FBF0, 0x1FBF9, 'EN'], + [0x1FBFA, 0x1FBFA, 'ON'], + [0x1FFFE, 0x1FFFF, 'BN'], + [0x2FFFE, 0x2FFFF, 'BN'], + [0x3FFFE, 0x3FFFF, 'BN'], + [0x4FFFE, 0x4FFFF, 'BN'], + [0x5FFFE, 0x5FFFF, 'BN'], + [0x6FFFE, 0x6FFFF, 'BN'], + [0x7FFFE, 0x7FFFF, 'BN'], + [0x8FFFE, 0x8FFFF, 'BN'], + [0x9FFFE, 0x9FFFF, 'BN'], + [0xAFFFE, 0xAFFFF, 'BN'], + [0xBFFFE, 0xBFFFF, 'BN'], + [0xCFFFE, 0xCFFFF, 'BN'], + [0xDFFFE, 0xE00FF, 'BN'], + [0xE0100, 0xE01EF, 'NSM'], + [0xE01F0, 0xE0FFF, 'BN'], + [0xEFFFE, 0xEFFFF, 'BN'], + [0xFFFFE, 0xFFFFF, 'BN'], + [0x10FFFE, 0x10FFFF, 'BN'], +]; diff --git a/dist/layout.d.ts b/dist/layout.d.ts new file mode 100644 index 00000000..5c45041f --- /dev/null +++ b/dist/layout.d.ts @@ -0,0 +1,71 @@ +import { type SegmentBreakKind, type WhiteSpaceMode, type WordBreakMode as AnalysisWordBreakMode } from './analysis.js'; +declare const preparedTextBrand: unique symbol; +type PreparedCore = { + widths: number[]; + lineEndFitAdvances: number[]; + lineEndPaintAdvances: number[]; + kinds: SegmentBreakKind[]; + simpleLineWalkFastPath: boolean; + segLevels: Int8Array | null; + breakableWidths: (number[] | null)[]; + breakablePrefixWidths: (number[] | null)[]; + discretionaryHyphenWidth: number; + tabStopAdvance: number; + chunks: PreparedLineChunk[]; +}; +export type PreparedText = { + readonly [preparedTextBrand]: true; +}; +type InternalPreparedText = PreparedText & PreparedCore; +export type PreparedTextWithSegments = InternalPreparedText & { + segments: string[]; +}; +export type LayoutCursor = { + segmentIndex: number; + graphemeIndex: number; +}; +export type LayoutResult = { + lineCount: number; + height: number; +}; +export type LineStats = { + lineCount: number; + maxLineWidth: number; +}; +export type LayoutLine = { + text: string; + width: number; + start: LayoutCursor; + end: LayoutCursor; +}; +export type LayoutLineRange = { + width: number; + start: LayoutCursor; + end: LayoutCursor; +}; +export type LayoutLinesResult = LayoutResult & { + lines: LayoutLine[]; +}; +export type WordBreakMode = AnalysisWordBreakMode; +export type PrepareOptions = { + whiteSpace?: WhiteSpaceMode; + wordBreak?: WordBreakMode; +}; +type PreparedLineChunk = { + startSegmentIndex: number; + endSegmentIndex: number; + consumedEndSegmentIndex: number; +}; +export declare function prepare(text: string, font: string, options?: PrepareOptions): PreparedText; +export declare function prepareWithSegments(text: string, font: string, options?: PrepareOptions): PreparedTextWithSegments; +export declare function layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult; +export declare function materializeLineRange(prepared: PreparedTextWithSegments, line: LayoutLineRange): LayoutLine; +export declare function walkLineRanges(prepared: PreparedTextWithSegments, maxWidth: number, onLine: (line: LayoutLineRange) => void): number; +export declare function measureLineStats(prepared: PreparedTextWithSegments, maxWidth: number): LineStats; +export declare function measureNaturalWidth(prepared: PreparedTextWithSegments): number; +export declare function layoutNextLine(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLine | null; +export declare function layoutNextLineRange(prepared: PreparedTextWithSegments, start: LayoutCursor, maxWidth: number): LayoutLineRange | null; +export declare function layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): LayoutLinesResult; +export declare function clearCache(): void; +export declare function setLocale(locale?: string): void; +export {}; diff --git a/dist/layout.js b/dist/layout.js new file mode 100644 index 00000000..d4b1a058 --- /dev/null +++ b/dist/layout.js @@ -0,0 +1,454 @@ +// Text measurement for browser environments using canvas measureText. +// +// Problem: DOM-based text measurement (getBoundingClientRect, offsetHeight) +// forces synchronous layout reflow. When components independently measure text, +// each measurement triggers a reflow of the entire document. This creates +// read/write interleaving that can cost 30ms+ per frame for 500 text blocks. +// +// Solution: two-phase measurement centered around canvas measureText. +// prepare(text, font) — segments text via Intl.Segmenter, measures each word +// via canvas, caches widths, and does one cached DOM calibration read per +// font when emoji correction is needed. Call once when text first appears. +// layout(prepared, maxWidth, lineHeight) — walks cached word widths with pure +// arithmetic to count lines and compute height. Call on every resize. +// ~0.0002ms per text. +// +// i18n: Intl.Segmenter handles CJK (per-character breaking), Thai, Arabic, etc. +// Bidi: simplified rich-path metadata for mixed LTR/RTL custom rendering. +// Punctuation merging: "better." measured as one unit (matches CSS behavior). +// Trailing whitespace: hangs past line edge without triggering breaks (CSS behavior). +// overflow-wrap: pre-measured grapheme widths enable character-level word breaking. +// +// Emoji correction: Chrome/Firefox canvas measures emoji wider than DOM at font +// sizes <24px on macOS (Apple Color Emoji). The inflation is constant per emoji +// grapheme at a given size, font-independent. Auto-detected by comparing canvas +// vs actual DOM emoji width (one cached DOM read per font). Safari canvas and +// DOM agree (both wider than fontSize), so correction = 0 there. +// +// Limitations: +// - system-ui font: canvas resolves to different optical variants than DOM on macOS. +// Use named fonts (Helvetica, Inter, etc.) for guaranteed accuracy. +// See RESEARCH.md "Discovery: system-ui font resolution mismatch". +// +// Based on Sebastian Markbage's text-layout research (github.com/chenglou/text-layout). +import { computeSegmentLevels } from './bidi.js'; +import { analyzeText, canContinueKeepAllTextRun, clearAnalysisCaches, endsWithClosingQuote, isCJK, isNumericRunSegment, kinsokuEnd, kinsokuStart, leftStickyPunctuation, setAnalysisLocale, } from './analysis.js'; +import { clearMeasurementCaches, getCorrectedSegmentWidth, getEngineProfile, getFontMeasurementState, getSegmentGraphemePrefixWidths, getSegmentGraphemeWidths, getSegmentMetrics, textMayContainEmoji, } from './measurement.js'; +import { countPreparedLines, layoutNextLineRange as stepPreparedLineRange, measurePreparedLineGeometry, walkPreparedLines, } from './line-break.js'; +let sharedGraphemeSegmenter = null; +// Rich-path only. Reuses grapheme splits while materializing multiple lines +// from the same prepared handle, without pushing that cache into the API. +let sharedLineTextCaches = new WeakMap(); +function getSharedGraphemeSegmenter() { + if (sharedGraphemeSegmenter === null) { + sharedGraphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + } + return sharedGraphemeSegmenter; +} +// --- Public API --- +function createEmptyPrepared(includeSegments) { + if (includeSegments) { + return { + widths: [], + lineEndFitAdvances: [], + lineEndPaintAdvances: [], + kinds: [], + simpleLineWalkFastPath: true, + segLevels: null, + breakableWidths: [], + breakablePrefixWidths: [], + discretionaryHyphenWidth: 0, + tabStopAdvance: 0, + chunks: [], + segments: [], + }; + } + return { + widths: [], + lineEndFitAdvances: [], + lineEndPaintAdvances: [], + kinds: [], + simpleLineWalkFastPath: true, + segLevels: null, + breakableWidths: [], + breakablePrefixWidths: [], + discretionaryHyphenWidth: 0, + tabStopAdvance: 0, + chunks: [], + }; +} +function buildBaseCjkUnits(segText, engineProfile) { + const units = []; + let unitText = ''; + let unitStart = 0; + function pushUnit() { + if (unitText.length === 0) + return; + units.push({ text: unitText, start: unitStart }); + unitText = ''; + } + for (const gs of getSharedGraphemeSegmenter().segment(segText)) { + const grapheme = gs.segment; + if (unitText.length === 0) { + unitText = grapheme; + unitStart = gs.index; + continue; + } + if (kinsokuEnd.has(unitText) || + kinsokuStart.has(grapheme) || + leftStickyPunctuation.has(grapheme) || + (engineProfile.carryCJKAfterClosingQuote && + isCJK(grapheme) && + endsWithClosingQuote(unitText))) { + unitText += grapheme; + continue; + } + if (!isCJK(unitText) && !isCJK(grapheme)) { + unitText += grapheme; + continue; + } + pushUnit(); + unitText = grapheme; + unitStart = gs.index; + } + pushUnit(); + return units; +} +function mergeKeepAllTextUnits(units) { + if (units.length <= 1) + return units; + const merged = [{ ...units[0] }]; + for (let i = 1; i < units.length; i++) { + const next = units[i]; + const previous = merged[merged.length - 1]; + if (canContinueKeepAllTextRun(previous.text) && + isCJK(previous.text)) { + previous.text += next.text; + continue; + } + merged.push({ ...next }); + } + return merged; +} +function measureAnalysis(analysis, font, includeSegments, wordBreak) { + const engineProfile = getEngineProfile(); + const { cache, emojiCorrection } = getFontMeasurementState(font, textMayContainEmoji(analysis.normalized)); + const discretionaryHyphenWidth = getCorrectedSegmentWidth('-', getSegmentMetrics('-', cache), emojiCorrection); + const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection); + const tabStopAdvance = spaceWidth * 8; + if (analysis.len === 0) + return createEmptyPrepared(includeSegments); + const widths = []; + const lineEndFitAdvances = []; + const lineEndPaintAdvances = []; + const kinds = []; + let simpleLineWalkFastPath = analysis.chunks.length <= 1; + const segStarts = includeSegments ? [] : null; + const breakableWidths = []; + const breakablePrefixWidths = []; + const segments = includeSegments ? [] : null; + const preparedStartByAnalysisIndex = Array.from({ length: analysis.len }); + function pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, breakable, breakablePrefix) { + if (kind !== 'text' && kind !== 'space' && kind !== 'zero-width-break') { + simpleLineWalkFastPath = false; + } + widths.push(width); + lineEndFitAdvances.push(lineEndFitAdvance); + lineEndPaintAdvances.push(lineEndPaintAdvance); + kinds.push(kind); + segStarts?.push(start); + breakableWidths.push(breakable); + breakablePrefixWidths.push(breakablePrefix); + if (segments !== null) + segments.push(text); + } + function pushMeasuredTextSegment(text, kind, start, wordLike, allowOverflowBreaks) { + const textMetrics = getSegmentMetrics(text, cache); + const width = getCorrectedSegmentWidth(text, textMetrics, emojiCorrection); + const lineEndFitAdvance = kind === 'space' || kind === 'preserved-space' || kind === 'zero-width-break' + ? 0 + : width; + const lineEndPaintAdvance = kind === 'space' || kind === 'zero-width-break' + ? 0 + : width; + if (allowOverflowBreaks && wordLike && text.length > 1) { + const graphemeWidths = getSegmentGraphemeWidths(text, textMetrics, cache, emojiCorrection); + const graphemePrefixWidths = engineProfile.preferPrefixWidthsForBreakableRuns || isNumericRunSegment(text) + ? getSegmentGraphemePrefixWidths(text, textMetrics, cache, emojiCorrection) + : null; + pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, graphemeWidths, graphemePrefixWidths); + return; + } + pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, null, null); + } + for (let mi = 0; mi < analysis.len; mi++) { + preparedStartByAnalysisIndex[mi] = widths.length; + const segText = analysis.texts[mi]; + const segWordLike = analysis.isWordLike[mi]; + const segKind = analysis.kinds[mi]; + const segStart = analysis.starts[mi]; + if (segKind === 'soft-hyphen') { + pushMeasuredSegment(segText, 0, discretionaryHyphenWidth, discretionaryHyphenWidth, segKind, segStart, null, null); + continue; + } + if (segKind === 'hard-break') { + pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null); + continue; + } + if (segKind === 'tab') { + pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null); + continue; + } + const segMetrics = getSegmentMetrics(segText, cache); + if (segKind === 'text' && segMetrics.containsCJK) { + const baseUnits = buildBaseCjkUnits(segText, engineProfile); + const measuredUnits = wordBreak === 'keep-all' + ? mergeKeepAllTextUnits(baseUnits) + : baseUnits; + for (let i = 0; i < measuredUnits.length; i++) { + const unit = measuredUnits[i]; + pushMeasuredTextSegment(unit.text, 'text', segStart + unit.start, segWordLike, wordBreak === 'keep-all' || !isCJK(unit.text)); + } + continue; + } + pushMeasuredTextSegment(segText, segKind, segStart, segWordLike, true); + } + const chunks = mapAnalysisChunksToPreparedChunks(analysis.chunks, preparedStartByAnalysisIndex, widths.length); + const segLevels = segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts); + if (segments !== null) { + return { + widths, + lineEndFitAdvances, + lineEndPaintAdvances, + kinds, + simpleLineWalkFastPath, + segLevels, + breakableWidths, + breakablePrefixWidths, + discretionaryHyphenWidth, + tabStopAdvance, + chunks, + segments, + }; + } + return { + widths, + lineEndFitAdvances, + lineEndPaintAdvances, + kinds, + simpleLineWalkFastPath, + segLevels, + breakableWidths, + breakablePrefixWidths, + discretionaryHyphenWidth, + tabStopAdvance, + chunks, + }; +} +function mapAnalysisChunksToPreparedChunks(chunks, preparedStartByAnalysisIndex, preparedEndSegmentIndex) { + const preparedChunks = []; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const startSegmentIndex = chunk.startSegmentIndex < preparedStartByAnalysisIndex.length + ? preparedStartByAnalysisIndex[chunk.startSegmentIndex] + : preparedEndSegmentIndex; + const endSegmentIndex = chunk.endSegmentIndex < preparedStartByAnalysisIndex.length + ? preparedStartByAnalysisIndex[chunk.endSegmentIndex] + : preparedEndSegmentIndex; + const consumedEndSegmentIndex = chunk.consumedEndSegmentIndex < preparedStartByAnalysisIndex.length + ? preparedStartByAnalysisIndex[chunk.consumedEndSegmentIndex] + : preparedEndSegmentIndex; + preparedChunks.push({ + startSegmentIndex, + endSegmentIndex, + consumedEndSegmentIndex, + }); + } + return preparedChunks; +} +function prepareInternal(text, font, includeSegments, options) { + const wordBreak = options?.wordBreak ?? 'normal'; + const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace, wordBreak); + return measureAnalysis(analysis, font, includeSegments, wordBreak); +} +// Prepare text for layout. Segments the text, measures each segment via canvas, +// and stores the widths for fast relayout at any width. Call once per text block +// (e.g. when a comment first appears). The result is width-independent — the +// same PreparedText can be laid out at any maxWidth and lineHeight via layout(). +// +// Steps: +// 1. Normalize collapsible whitespace (CSS white-space: normal behavior) +// 2. Segment via Intl.Segmenter (handles CJK, Thai, etc.) +// 3. Merge punctuation into preceding word ("better." as one unit) +// 4. Split CJK words into individual graphemes (per-character line breaks) +// 5. Measure each segment via canvas measureText, cache by (segment, font) +// 6. Pre-measure graphemes of long words (for overflow-wrap: break-word) +// 7. Correct emoji canvas inflation (auto-detected per font size) +// 8. Optionally compute rich-path bidi metadata for custom renderers +export function prepare(text, font, options) { + return prepareInternal(text, font, false, options); +} +// Rich variant used by callers that need enough information to render the +// laid-out lines themselves. +export function prepareWithSegments(text, font, options) { + return prepareInternal(text, font, true, options); +} +function getInternalPrepared(prepared) { + return prepared; +} +// Layout prepared text at a given max width and caller-provided lineHeight. +// Pure arithmetic on cached widths — no canvas calls, no DOM reads, no string +// operations, no allocations. +// ~0.0002ms per text block. Call on every resize. +// +// Line breaking rules (matching CSS white-space: normal + overflow-wrap: break-word): +// - Break before any non-space segment that would overflow the line +// - Trailing whitespace hangs past the line edge (doesn't trigger breaks) +// - Segments wider than maxWidth are broken at grapheme boundaries +export function layout(prepared, maxWidth, lineHeight) { + // Keep the resize hot path specialized. `layoutWithLines()` shares the same + // break semantics but also tracks line ranges; the extra bookkeeping is too + // expensive to pay on every hot-path `layout()` call. + const lineCount = countPreparedLines(getInternalPrepared(prepared), maxWidth); + return { lineCount, height: lineCount * lineHeight }; +} +function getSegmentGraphemes(segmentIndex, segments, cache) { + let graphemes = cache.get(segmentIndex); + if (graphemes !== undefined) + return graphemes; + graphemes = []; + const graphemeSegmenter = getSharedGraphemeSegmenter(); + for (const gs of graphemeSegmenter.segment(segments[segmentIndex])) { + graphemes.push(gs.segment); + } + cache.set(segmentIndex, graphemes); + return graphemes; +} +function getLineTextCache(prepared) { + let cache = sharedLineTextCaches.get(prepared); + if (cache !== undefined) + return cache; + cache = new Map(); + sharedLineTextCaches.set(prepared, cache); + return cache; +} +function lineHasDiscretionaryHyphen(kinds, startSegmentIndex, startGraphemeIndex, endSegmentIndex) { + return (endSegmentIndex > 0 && + kinds[endSegmentIndex - 1] === 'soft-hyphen' && + !(startSegmentIndex === endSegmentIndex && startGraphemeIndex > 0)); +} +function buildLineTextFromRange(segments, kinds, cache, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) { + let text = ''; + const endsWithDiscretionaryHyphen = lineHasDiscretionaryHyphen(kinds, startSegmentIndex, startGraphemeIndex, endSegmentIndex); + for (let i = startSegmentIndex; i < endSegmentIndex; i++) { + if (kinds[i] === 'soft-hyphen' || kinds[i] === 'hard-break') + continue; + if (i === startSegmentIndex && startGraphemeIndex > 0) { + text += getSegmentGraphemes(i, segments, cache).slice(startGraphemeIndex).join(''); + } + else { + text += segments[i]; + } + } + if (endGraphemeIndex > 0) { + if (endsWithDiscretionaryHyphen) + text += '-'; + text += getSegmentGraphemes(endSegmentIndex, segments, cache).slice(startSegmentIndex === endSegmentIndex ? startGraphemeIndex : 0, endGraphemeIndex).join(''); + } + else if (endsWithDiscretionaryHyphen) { + text += '-'; + } + return text; +} +function createLayoutLine(prepared, cache, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) { + return { + text: buildLineTextFromRange(prepared.segments, prepared.kinds, cache, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex), + width, + start: { + segmentIndex: startSegmentIndex, + graphemeIndex: startGraphemeIndex, + }, + end: { + segmentIndex: endSegmentIndex, + graphemeIndex: endGraphemeIndex, + }, + }; +} +function materializeLayoutLine(prepared, cache, line) { + return createLayoutLine(prepared, cache, line.width, line.startSegmentIndex, line.startGraphemeIndex, line.endSegmentIndex, line.endGraphemeIndex); +} +function toLayoutLineRange(line) { + return { + width: line.width, + start: { + segmentIndex: line.startSegmentIndex, + graphemeIndex: line.startGraphemeIndex, + }, + end: { + segmentIndex: line.endSegmentIndex, + graphemeIndex: line.endGraphemeIndex, + }, + }; +} +export function materializeLineRange(prepared, line) { + return createLayoutLine(prepared, getLineTextCache(prepared), line.width, line.start.segmentIndex, line.start.graphemeIndex, line.end.segmentIndex, line.end.graphemeIndex); +} +// Batch low-level line-range pass. This is the non-materializing counterpart +// to layoutWithLines(), useful for shrinkwrap and other aggregate stats work. +export function walkLineRanges(prepared, maxWidth, onLine) { + if (prepared.widths.length === 0) + return 0; + return walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => { + onLine(toLayoutLineRange(line)); + }); +} +export function measureLineStats(prepared, maxWidth) { + return measurePreparedLineGeometry(getInternalPrepared(prepared), maxWidth); +} +// Intrinsic-width helper for rich/userland layout work. This asks "how wide is +// the prepared text when container width is not the thing forcing wraps?". +// Explicit hard breaks still count, so this returns the widest forced line. +export function measureNaturalWidth(prepared) { + let maxWidth = 0; + walkLineRanges(prepared, Number.POSITIVE_INFINITY, line => { + if (line.width > maxWidth) + maxWidth = line.width; + }); + return maxWidth; +} +export function layoutNextLine(prepared, start, maxWidth) { + const line = layoutNextLineRange(prepared, start, maxWidth); + if (line === null) + return null; + return materializeLineRange(prepared, line); +} +export function layoutNextLineRange(prepared, start, maxWidth) { + const line = stepPreparedLineRange(prepared, start, maxWidth); + if (line === null) + return null; + return toLayoutLineRange(line); +} +// Rich layout API for callers that want the actual line contents and widths. +// Caller still supplies lineHeight at layout time. Mirrors layout()'s break +// decisions, but keeps extra per-line bookkeeping so it should stay off the +// resize hot path. +export function layoutWithLines(prepared, maxWidth, lineHeight) { + const lines = []; + if (prepared.widths.length === 0) + return { lineCount: 0, height: 0, lines }; + const graphemeCache = getLineTextCache(prepared); + const lineCount = walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => { + lines.push(materializeLayoutLine(prepared, graphemeCache, line)); + }); + return { lineCount, height: lineCount * lineHeight, lines }; +} +export function clearCache() { + clearAnalysisCaches(); + sharedGraphemeSegmenter = null; + sharedLineTextCaches = new WeakMap(); + clearMeasurementCaches(); +} +export function setLocale(locale) { + setAnalysisLocale(locale); + clearCache(); +} diff --git a/dist/line-break.d.ts b/dist/line-break.d.ts new file mode 100644 index 00000000..6bdf8d31 --- /dev/null +++ b/dist/line-break.d.ts @@ -0,0 +1,37 @@ +import type { SegmentBreakKind } from './analysis.js'; +export type LineBreakCursor = { + segmentIndex: number; + graphemeIndex: number; +}; +export type PreparedLineBreakData = { + widths: number[]; + lineEndFitAdvances: number[]; + lineEndPaintAdvances: number[]; + kinds: SegmentBreakKind[]; + simpleLineWalkFastPath: boolean; + breakableWidths: (number[] | null)[]; + breakablePrefixWidths: (number[] | null)[]; + discretionaryHyphenWidth: number; + tabStopAdvance: number; + chunks: { + startSegmentIndex: number; + endSegmentIndex: number; + consumedEndSegmentIndex: number; + }[]; +}; +export type InternalLayoutLine = { + startSegmentIndex: number; + startGraphemeIndex: number; + endSegmentIndex: number; + endGraphemeIndex: number; + width: number; +}; +export declare function normalizeLineStart(prepared: PreparedLineBreakData, start: LineBreakCursor): LineBreakCursor | null; +export declare function countPreparedLines(prepared: PreparedLineBreakData, maxWidth: number): number; +export declare function walkPreparedLines(prepared: PreparedLineBreakData, maxWidth: number, onLine?: (line: InternalLayoutLine) => void): number; +export declare function layoutNextLineRange(prepared: PreparedLineBreakData, start: LineBreakCursor, maxWidth: number): InternalLayoutLine | null; +export declare function stepPreparedLineGeometry(prepared: PreparedLineBreakData, cursor: LineBreakCursor, maxWidth: number): number | null; +export declare function measurePreparedLineGeometry(prepared: PreparedLineBreakData, maxWidth: number): { + lineCount: number; + maxLineWidth: number; +}; diff --git a/dist/line-break.js b/dist/line-break.js new file mode 100644 index 00000000..d9148e68 --- /dev/null +++ b/dist/line-break.js @@ -0,0 +1,848 @@ +import { getEngineProfile } from './measurement.js'; +function normalizeSimpleLineStartSegmentIndex(prepared, segmentIndex) { + while (segmentIndex < prepared.widths.length) { + const kind = prepared.kinds[segmentIndex]; + if (kind !== 'space' && kind !== 'zero-width-break' && kind !== 'soft-hyphen') + break; + segmentIndex++; + } + return segmentIndex; +} +function getTabAdvance(lineWidth, tabStopAdvance) { + if (tabStopAdvance <= 0) + return 0; + const remainder = lineWidth % tabStopAdvance; + if (Math.abs(remainder) <= 1e-6) + return tabStopAdvance; + return tabStopAdvance - remainder; +} +function fitSoftHyphenBreak(graphemeWidths, initialWidth, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, cumulativeWidths) { + let fitCount = 0; + let fittedWidth = initialWidth; + while (fitCount < graphemeWidths.length) { + const nextWidth = cumulativeWidths + ? initialWidth + graphemeWidths[fitCount] + : fittedWidth + graphemeWidths[fitCount]; + const nextLineWidth = fitCount + 1 < graphemeWidths.length + ? nextWidth + discretionaryHyphenWidth + : nextWidth; + if (nextLineWidth > maxWidth + lineFitEpsilon) + break; + fittedWidth = nextWidth; + fitCount++; + } + return { fitCount, fittedWidth }; +} +function findChunkIndexForStart(prepared, segmentIndex) { + let lo = 0; + let hi = prepared.chunks.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (segmentIndex < prepared.chunks[mid].consumedEndSegmentIndex) { + hi = mid; + } + else { + lo = mid + 1; + } + } + return lo < prepared.chunks.length ? lo : -1; +} +function normalizeLineStartInChunk(prepared, chunkIndex, cursor) { + let segmentIndex = cursor.segmentIndex; + if (cursor.graphemeIndex > 0) + return chunkIndex; + const chunk = prepared.chunks[chunkIndex]; + if (chunk.startSegmentIndex === chunk.endSegmentIndex && segmentIndex === chunk.startSegmentIndex) { + cursor.segmentIndex = segmentIndex; + cursor.graphemeIndex = 0; + return chunkIndex; + } + if (segmentIndex < chunk.startSegmentIndex) + segmentIndex = chunk.startSegmentIndex; + while (segmentIndex < chunk.endSegmentIndex) { + const kind = prepared.kinds[segmentIndex]; + if (kind !== 'space' && kind !== 'zero-width-break' && kind !== 'soft-hyphen') { + cursor.segmentIndex = segmentIndex; + cursor.graphemeIndex = 0; + return chunkIndex; + } + segmentIndex++; + } + if (chunk.consumedEndSegmentIndex >= prepared.widths.length) + return -1; + cursor.segmentIndex = chunk.consumedEndSegmentIndex; + cursor.graphemeIndex = 0; + return chunkIndex + 1; +} +function normalizeLineStartChunkIndex(prepared, cursor) { + if (cursor.segmentIndex >= prepared.widths.length) + return -1; + const chunkIndex = findChunkIndexForStart(prepared, cursor.segmentIndex); + if (chunkIndex < 0) + return -1; + return normalizeLineStartInChunk(prepared, chunkIndex, cursor); +} +function normalizeLineStartChunkIndexFromHint(prepared, chunkIndex, cursor) { + if (cursor.segmentIndex >= prepared.widths.length) + return -1; + let nextChunkIndex = chunkIndex; + while (nextChunkIndex < prepared.chunks.length && + cursor.segmentIndex >= prepared.chunks[nextChunkIndex].consumedEndSegmentIndex) { + nextChunkIndex++; + } + if (nextChunkIndex >= prepared.chunks.length) + return -1; + return normalizeLineStartInChunk(prepared, nextChunkIndex, cursor); +} +export function normalizeLineStart(prepared, start) { + const cursor = { + segmentIndex: start.segmentIndex, + graphemeIndex: start.graphemeIndex, + }; + const chunkIndex = normalizeLineStartChunkIndex(prepared, cursor); + return chunkIndex < 0 ? null : cursor; +} +export function countPreparedLines(prepared, maxWidth) { + if (prepared.simpleLineWalkFastPath) { + return walkPreparedLinesSimple(prepared, maxWidth); + } + return walkPreparedLines(prepared, maxWidth); +} +function walkPreparedLinesSimple(prepared, maxWidth, onLine) { + const { widths, kinds, breakableWidths, breakablePrefixWidths } = prepared; + if (widths.length === 0) + return 0; + const engineProfile = getEngineProfile(); + const lineFitEpsilon = engineProfile.lineFitEpsilon; + const fitLimit = maxWidth + lineFitEpsilon; + let lineCount = 0; + let lineW = 0; + let hasContent = false; + let lineStartSegmentIndex = 0; + let lineStartGraphemeIndex = 0; + let lineEndSegmentIndex = 0; + let lineEndGraphemeIndex = 0; + let pendingBreakSegmentIndex = -1; + let pendingBreakPaintWidth = 0; + function clearPendingBreak() { + pendingBreakSegmentIndex = -1; + pendingBreakPaintWidth = 0; + } + function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) { + lineCount++; + onLine?.({ + startSegmentIndex: lineStartSegmentIndex, + startGraphemeIndex: lineStartGraphemeIndex, + endSegmentIndex, + endGraphemeIndex, + width, + }); + lineW = 0; + hasContent = false; + clearPendingBreak(); + } + function startLineAtSegment(segmentIndex, width) { + hasContent = true; + lineStartSegmentIndex = segmentIndex; + lineStartGraphemeIndex = 0; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + lineW = width; + } + function startLineAtGrapheme(segmentIndex, graphemeIndex, width) { + hasContent = true; + lineStartSegmentIndex = segmentIndex; + lineStartGraphemeIndex = graphemeIndex; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = graphemeIndex + 1; + lineW = width; + } + function appendWholeSegment(segmentIndex, width) { + if (!hasContent) { + startLineAtSegment(segmentIndex, width); + return; + } + lineW += width; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) { + const gWidths = breakableWidths[segmentIndex]; + const gPrefixWidths = breakablePrefixWidths[segmentIndex] ?? null; + let previousPrefixWidth = gPrefixWidths === null || startGraphemeIndex === 0 ? 0 : gPrefixWidths[startGraphemeIndex - 1]; + for (let g = startGraphemeIndex; g < gWidths.length; g++) { + const gw = gPrefixWidths === null ? gWidths[g] : gPrefixWidths[g] - previousPrefixWidth; + if (!hasContent) { + startLineAtGrapheme(segmentIndex, g, gw); + } + else if (lineW + gw > fitLimit) { + emitCurrentLine(); + startLineAtGrapheme(segmentIndex, g, gw); + } + else { + lineW += gw; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = g + 1; + } + if (gPrefixWidths !== null) + previousPrefixWidth = gPrefixWidths[g]; + } + if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === gWidths.length) { + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + } + let i = 0; + while (i < widths.length) { + if (!hasContent) { + i = normalizeSimpleLineStartSegmentIndex(prepared, i); + if (i >= widths.length) + break; + } + const w = widths[i]; + const kind = kinds[i]; + const breakAfter = kind === 'space' || kind === 'preserved-space' || kind === 'tab' || kind === 'zero-width-break' || kind === 'soft-hyphen'; + if (!hasContent) { + if (w > maxWidth && breakableWidths[i] !== null) { + appendBreakableSegmentFrom(i, 0); + } + else { + startLineAtSegment(i, w); + } + if (breakAfter) { + pendingBreakSegmentIndex = i + 1; + pendingBreakPaintWidth = lineW - w; + } + i++; + continue; + } + const newW = lineW + w; + if (newW > fitLimit) { + if (breakAfter) { + appendWholeSegment(i, w); + emitCurrentLine(i + 1, 0, lineW - w); + i++; + continue; + } + if (pendingBreakSegmentIndex >= 0) { + if (lineEndSegmentIndex > pendingBreakSegmentIndex || + (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) { + emitCurrentLine(); + continue; + } + emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); + continue; + } + if (w > maxWidth && breakableWidths[i] !== null) { + emitCurrentLine(); + appendBreakableSegmentFrom(i, 0); + i++; + continue; + } + emitCurrentLine(); + continue; + } + appendWholeSegment(i, w); + if (breakAfter) { + pendingBreakSegmentIndex = i + 1; + pendingBreakPaintWidth = lineW - w; + } + i++; + } + if (hasContent) + emitCurrentLine(); + return lineCount; +} +export function walkPreparedLines(prepared, maxWidth, onLine) { + if (prepared.simpleLineWalkFastPath) { + return walkPreparedLinesSimple(prepared, maxWidth, onLine); + } + const { widths, lineEndFitAdvances, lineEndPaintAdvances, kinds, breakableWidths, breakablePrefixWidths, discretionaryHyphenWidth, tabStopAdvance, chunks, } = prepared; + if (widths.length === 0 || chunks.length === 0) + return 0; + const engineProfile = getEngineProfile(); + const lineFitEpsilon = engineProfile.lineFitEpsilon; + const fitLimit = maxWidth + lineFitEpsilon; + let lineCount = 0; + let lineW = 0; + let hasContent = false; + let lineStartSegmentIndex = 0; + let lineStartGraphemeIndex = 0; + let lineEndSegmentIndex = 0; + let lineEndGraphemeIndex = 0; + let pendingBreakSegmentIndex = -1; + let pendingBreakFitWidth = 0; + let pendingBreakPaintWidth = 0; + let pendingBreakKind = null; + function clearPendingBreak() { + pendingBreakSegmentIndex = -1; + pendingBreakFitWidth = 0; + pendingBreakPaintWidth = 0; + pendingBreakKind = null; + } + function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) { + lineCount++; + onLine?.({ + startSegmentIndex: lineStartSegmentIndex, + startGraphemeIndex: lineStartGraphemeIndex, + endSegmentIndex, + endGraphemeIndex, + width, + }); + lineW = 0; + hasContent = false; + clearPendingBreak(); + } + function startLineAtSegment(segmentIndex, width) { + hasContent = true; + lineStartSegmentIndex = segmentIndex; + lineStartGraphemeIndex = 0; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + lineW = width; + } + function startLineAtGrapheme(segmentIndex, graphemeIndex, width) { + hasContent = true; + lineStartSegmentIndex = segmentIndex; + lineStartGraphemeIndex = graphemeIndex; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = graphemeIndex + 1; + lineW = width; + } + function appendWholeSegment(segmentIndex, width) { + if (!hasContent) { + startLineAtSegment(segmentIndex, width); + return; + } + lineW += width; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + function updatePendingBreakForWholeSegment(kind, breakAfter, segmentIndex, segmentWidth) { + if (!breakAfter) + return; + const fitAdvance = kind === 'tab' ? 0 : lineEndFitAdvances[segmentIndex]; + const paintAdvance = kind === 'tab' ? segmentWidth : lineEndPaintAdvances[segmentIndex]; + pendingBreakSegmentIndex = segmentIndex + 1; + pendingBreakFitWidth = lineW - segmentWidth + fitAdvance; + pendingBreakPaintWidth = lineW - segmentWidth + paintAdvance; + pendingBreakKind = kind; + } + function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) { + const gWidths = breakableWidths[segmentIndex]; + const gPrefixWidths = breakablePrefixWidths[segmentIndex] ?? null; + let previousPrefixWidth = gPrefixWidths === null || startGraphemeIndex === 0 ? 0 : gPrefixWidths[startGraphemeIndex - 1]; + for (let g = startGraphemeIndex; g < gWidths.length; g++) { + const gw = gPrefixWidths === null ? gWidths[g] : gPrefixWidths[g] - previousPrefixWidth; + if (!hasContent) { + startLineAtGrapheme(segmentIndex, g, gw); + } + else if (lineW + gw > fitLimit) { + emitCurrentLine(); + startLineAtGrapheme(segmentIndex, g, gw); + } + else { + lineW += gw; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = g + 1; + } + if (gPrefixWidths !== null) + previousPrefixWidth = gPrefixWidths[g]; + } + if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === gWidths.length) { + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + } + function continueSoftHyphenBreakableSegment(segmentIndex) { + if (pendingBreakKind !== 'soft-hyphen') + return false; + const gWidths = breakableWidths[segmentIndex]; + if (gWidths === null) + return false; + const fitWidths = breakablePrefixWidths[segmentIndex] ?? gWidths; + const usesPrefixWidths = fitWidths !== gWidths; + const { fitCount, fittedWidth } = fitSoftHyphenBreak(fitWidths, lineW, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, usesPrefixWidths); + if (fitCount === 0) + return false; + lineW = fittedWidth; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = fitCount; + clearPendingBreak(); + if (fitCount === gWidths.length) { + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + return true; + } + emitCurrentLine(segmentIndex, fitCount, fittedWidth + discretionaryHyphenWidth); + appendBreakableSegmentFrom(segmentIndex, fitCount); + return true; + } + function emitEmptyChunk(chunk) { + lineCount++; + onLine?.({ + startSegmentIndex: chunk.startSegmentIndex, + startGraphemeIndex: 0, + endSegmentIndex: chunk.consumedEndSegmentIndex, + endGraphemeIndex: 0, + width: 0, + }); + clearPendingBreak(); + } + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (chunk.startSegmentIndex === chunk.endSegmentIndex) { + emitEmptyChunk(chunk); + continue; + } + hasContent = false; + lineW = 0; + lineStartSegmentIndex = chunk.startSegmentIndex; + lineStartGraphemeIndex = 0; + lineEndSegmentIndex = chunk.startSegmentIndex; + lineEndGraphemeIndex = 0; + clearPendingBreak(); + let i = chunk.startSegmentIndex; + while (i < chunk.endSegmentIndex) { + const kind = kinds[i]; + const breakAfter = kind === 'space' || kind === 'preserved-space' || kind === 'tab' || kind === 'zero-width-break' || kind === 'soft-hyphen'; + const w = kind === 'tab' ? getTabAdvance(lineW, tabStopAdvance) : widths[i]; + if (kind === 'soft-hyphen') { + if (hasContent) { + lineEndSegmentIndex = i + 1; + lineEndGraphemeIndex = 0; + pendingBreakSegmentIndex = i + 1; + pendingBreakFitWidth = lineW + discretionaryHyphenWidth; + pendingBreakPaintWidth = lineW + discretionaryHyphenWidth; + pendingBreakKind = kind; + } + i++; + continue; + } + if (!hasContent) { + if (w > maxWidth && breakableWidths[i] !== null) { + appendBreakableSegmentFrom(i, 0); + } + else { + startLineAtSegment(i, w); + } + updatePendingBreakForWholeSegment(kind, breakAfter, i, w); + i++; + continue; + } + const newW = lineW + w; + if (newW > fitLimit) { + const currentBreakFitWidth = lineW + (kind === 'tab' ? 0 : lineEndFitAdvances[i]); + const currentBreakPaintWidth = lineW + (kind === 'tab' ? w : lineEndPaintAdvances[i]); + if (pendingBreakKind === 'soft-hyphen' && + engineProfile.preferEarlySoftHyphenBreak && + pendingBreakFitWidth <= fitLimit) { + emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); + continue; + } + if (pendingBreakKind === 'soft-hyphen' && continueSoftHyphenBreakableSegment(i)) { + i++; + continue; + } + if (breakAfter && currentBreakFitWidth <= fitLimit) { + appendWholeSegment(i, w); + emitCurrentLine(i + 1, 0, currentBreakPaintWidth); + i++; + continue; + } + if (pendingBreakSegmentIndex >= 0 && pendingBreakFitWidth <= fitLimit) { + if (lineEndSegmentIndex > pendingBreakSegmentIndex || + (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) { + emitCurrentLine(); + continue; + } + const nextSegmentIndex = pendingBreakSegmentIndex; + emitCurrentLine(nextSegmentIndex, 0, pendingBreakPaintWidth); + i = nextSegmentIndex; + continue; + } + if (w > maxWidth && breakableWidths[i] !== null) { + emitCurrentLine(); + appendBreakableSegmentFrom(i, 0); + i++; + continue; + } + emitCurrentLine(); + continue; + } + appendWholeSegment(i, w); + updatePendingBreakForWholeSegment(kind, breakAfter, i, w); + i++; + } + if (hasContent) { + const finalPaintWidth = pendingBreakSegmentIndex === chunk.consumedEndSegmentIndex + ? pendingBreakPaintWidth + : lineW; + emitCurrentLine(chunk.consumedEndSegmentIndex, 0, finalPaintWidth); + } + } + return lineCount; +} +function stepPreparedChunkLineGeometry(prepared, cursor, chunkIndex, maxWidth) { + const chunk = prepared.chunks[chunkIndex]; + if (chunk.startSegmentIndex === chunk.endSegmentIndex) { + cursor.segmentIndex = chunk.consumedEndSegmentIndex; + cursor.graphemeIndex = 0; + return 0; + } + const { widths, lineEndFitAdvances, lineEndPaintAdvances, kinds, breakableWidths, breakablePrefixWidths, discretionaryHyphenWidth, tabStopAdvance, } = prepared; + const engineProfile = getEngineProfile(); + const lineFitEpsilon = engineProfile.lineFitEpsilon; + const fitLimit = maxWidth + lineFitEpsilon; + let lineW = 0; + let hasContent = false; + let lineEndSegmentIndex = cursor.segmentIndex; + let lineEndGraphemeIndex = cursor.graphemeIndex; + let pendingBreakSegmentIndex = -1; + let pendingBreakFitWidth = 0; + let pendingBreakPaintWidth = 0; + let pendingBreakKind = null; + function clearPendingBreak() { + pendingBreakSegmentIndex = -1; + pendingBreakFitWidth = 0; + pendingBreakPaintWidth = 0; + pendingBreakKind = null; + } + function finishLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) { + if (!hasContent) + return null; + cursor.segmentIndex = endSegmentIndex; + cursor.graphemeIndex = endGraphemeIndex; + return width; + } + function startLineAtSegment(segmentIndex, width) { + hasContent = true; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + lineW = width; + } + function startLineAtGrapheme(segmentIndex, graphemeIndex, width) { + hasContent = true; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = graphemeIndex + 1; + lineW = width; + } + function appendWholeSegment(segmentIndex, width) { + if (!hasContent) { + startLineAtSegment(segmentIndex, width); + return; + } + lineW += width; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + function updatePendingBreakForWholeSegment(kind, breakAfter, segmentIndex, segmentWidth) { + if (!breakAfter) + return; + const fitAdvance = kind === 'tab' ? 0 : lineEndFitAdvances[segmentIndex]; + const paintAdvance = kind === 'tab' ? segmentWidth : lineEndPaintAdvances[segmentIndex]; + pendingBreakSegmentIndex = segmentIndex + 1; + pendingBreakFitWidth = lineW - segmentWidth + fitAdvance; + pendingBreakPaintWidth = lineW - segmentWidth + paintAdvance; + pendingBreakKind = kind; + } + function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) { + const gWidths = breakableWidths[segmentIndex]; + const gPrefixWidths = breakablePrefixWidths[segmentIndex] ?? null; + let previousPrefixWidth = gPrefixWidths === null || startGraphemeIndex === 0 ? 0 : gPrefixWidths[startGraphemeIndex - 1]; + for (let g = startGraphemeIndex; g < gWidths.length; g++) { + const gw = gPrefixWidths === null ? gWidths[g] : gPrefixWidths[g] - previousPrefixWidth; + if (!hasContent) { + startLineAtGrapheme(segmentIndex, g, gw); + } + else { + if (lineW + gw > fitLimit) { + return finishLine(); + } + lineW += gw; + lineEndSegmentIndex = segmentIndex; + lineEndGraphemeIndex = g + 1; + } + if (gPrefixWidths !== null) + previousPrefixWidth = gPrefixWidths[g]; + } + if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === gWidths.length) { + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + } + return null; + } + function maybeFinishAtSoftHyphen(segmentIndex) { + if (pendingBreakKind !== 'soft-hyphen' || pendingBreakSegmentIndex < 0) + return null; + const gWidths = breakableWidths[segmentIndex] ?? null; + if (gWidths !== null) { + const fitWidths = breakablePrefixWidths[segmentIndex] ?? gWidths; + const usesPrefixWidths = fitWidths !== gWidths; + const { fitCount, fittedWidth } = fitSoftHyphenBreak(fitWidths, lineW, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, usesPrefixWidths); + if (fitCount === gWidths.length) { + lineW = fittedWidth; + lineEndSegmentIndex = segmentIndex + 1; + lineEndGraphemeIndex = 0; + clearPendingBreak(); + return null; + } + if (fitCount > 0) { + return finishLine(segmentIndex, fitCount, fittedWidth + discretionaryHyphenWidth); + } + } + if (pendingBreakFitWidth <= fitLimit) { + return finishLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); + } + return null; + } + for (let i = cursor.segmentIndex; i < chunk.endSegmentIndex; i++) { + const kind = kinds[i]; + const breakAfter = kind === 'space' || kind === 'preserved-space' || kind === 'tab' || kind === 'zero-width-break' || kind === 'soft-hyphen'; + const startGraphemeIndex = i === cursor.segmentIndex ? cursor.graphemeIndex : 0; + const w = kind === 'tab' ? getTabAdvance(lineW, tabStopAdvance) : widths[i]; + if (kind === 'soft-hyphen' && startGraphemeIndex === 0) { + if (hasContent) { + lineEndSegmentIndex = i + 1; + lineEndGraphemeIndex = 0; + pendingBreakSegmentIndex = i + 1; + pendingBreakFitWidth = lineW + discretionaryHyphenWidth; + pendingBreakPaintWidth = lineW + discretionaryHyphenWidth; + pendingBreakKind = kind; + } + continue; + } + if (!hasContent) { + if (startGraphemeIndex > 0) { + const line = appendBreakableSegmentFrom(i, startGraphemeIndex); + if (line !== null) + return line; + } + else if (w > maxWidth && breakableWidths[i] !== null) { + const line = appendBreakableSegmentFrom(i, 0); + if (line !== null) + return line; + } + else { + startLineAtSegment(i, w); + } + updatePendingBreakForWholeSegment(kind, breakAfter, i, w); + continue; + } + const newW = lineW + w; + if (newW > fitLimit) { + const currentBreakFitWidth = lineW + (kind === 'tab' ? 0 : lineEndFitAdvances[i]); + const currentBreakPaintWidth = lineW + (kind === 'tab' ? w : lineEndPaintAdvances[i]); + if (pendingBreakKind === 'soft-hyphen' && + engineProfile.preferEarlySoftHyphenBreak && + pendingBreakFitWidth <= fitLimit) { + return finishLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); + } + const softBreakLine = maybeFinishAtSoftHyphen(i); + if (softBreakLine !== null) + return softBreakLine; + if (breakAfter && currentBreakFitWidth <= fitLimit) { + appendWholeSegment(i, w); + return finishLine(i + 1, 0, currentBreakPaintWidth); + } + if (pendingBreakSegmentIndex >= 0 && pendingBreakFitWidth <= fitLimit) { + if (lineEndSegmentIndex > pendingBreakSegmentIndex || + (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) { + return finishLine(); + } + return finishLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth); + } + if (w > maxWidth && breakableWidths[i] !== null) { + const currentLine = finishLine(); + if (currentLine !== null) + return currentLine; + const line = appendBreakableSegmentFrom(i, 0); + if (line !== null) + return line; + } + return finishLine(); + } + appendWholeSegment(i, w); + updatePendingBreakForWholeSegment(kind, breakAfter, i, w); + } + if (pendingBreakSegmentIndex === chunk.consumedEndSegmentIndex && lineEndGraphemeIndex === 0) { + return finishLine(chunk.consumedEndSegmentIndex, 0, pendingBreakPaintWidth); + } + return finishLine(chunk.consumedEndSegmentIndex, 0, lineW); +} +function stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth) { + const { widths, kinds, breakableWidths, breakablePrefixWidths } = prepared; + const engineProfile = getEngineProfile(); + const lineFitEpsilon = engineProfile.lineFitEpsilon; + const fitLimit = maxWidth + lineFitEpsilon; + let lineW = 0; + let hasContent = false; + let lineEndSegmentIndex = cursor.segmentIndex; + let lineEndGraphemeIndex = cursor.graphemeIndex; + let pendingBreakSegmentIndex = -1; + let pendingBreakPaintWidth = 0; + for (let i = cursor.segmentIndex; i < widths.length; i++) { + const w = widths[i]; + const kind = kinds[i]; + const breakAfter = kind === 'space' || kind === 'preserved-space' || kind === 'tab' || kind === 'zero-width-break' || kind === 'soft-hyphen'; + const startGraphemeIndex = i === cursor.segmentIndex ? cursor.graphemeIndex : 0; + const breakableWidth = breakableWidths[i]; + if (!hasContent) { + if (startGraphemeIndex > 0 || (w > maxWidth && breakableWidth !== null)) { + const gWidths = breakableWidth; + const gPrefixWidths = breakablePrefixWidths[i] ?? null; + let previousPrefixWidth = gPrefixWidths === null || startGraphemeIndex === 0 + ? 0 + : gPrefixWidths[startGraphemeIndex - 1]; + const firstGraphemeWidth = gPrefixWidths === null + ? gWidths[startGraphemeIndex] + : gPrefixWidths[startGraphemeIndex] - previousPrefixWidth; + hasContent = true; + lineW = firstGraphemeWidth; + lineEndSegmentIndex = i; + lineEndGraphemeIndex = startGraphemeIndex + 1; + if (gPrefixWidths !== null) + previousPrefixWidth = gPrefixWidths[startGraphemeIndex]; + for (let g = startGraphemeIndex + 1; g < gWidths.length; g++) { + const gw = gPrefixWidths === null ? gWidths[g] : gPrefixWidths[g] - previousPrefixWidth; + if (lineW + gw > fitLimit) { + cursor.segmentIndex = lineEndSegmentIndex; + cursor.graphemeIndex = lineEndGraphemeIndex; + return lineW; + } + lineW += gw; + lineEndSegmentIndex = i; + lineEndGraphemeIndex = g + 1; + if (gPrefixWidths !== null) + previousPrefixWidth = gPrefixWidths[g]; + } + if (lineEndSegmentIndex === i && lineEndGraphemeIndex === gWidths.length) { + lineEndSegmentIndex = i + 1; + lineEndGraphemeIndex = 0; + } + } + else { + hasContent = true; + lineW = w; + lineEndSegmentIndex = i + 1; + lineEndGraphemeIndex = 0; + } + if (breakAfter) { + pendingBreakSegmentIndex = i + 1; + pendingBreakPaintWidth = lineW - w; + } + continue; + } + if (lineW + w > fitLimit) { + if (breakAfter) { + cursor.segmentIndex = i + 1; + cursor.graphemeIndex = 0; + return lineW; + } + if (pendingBreakSegmentIndex >= 0) { + if (lineEndSegmentIndex > pendingBreakSegmentIndex || + (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) { + cursor.segmentIndex = lineEndSegmentIndex; + cursor.graphemeIndex = lineEndGraphemeIndex; + return lineW; + } + cursor.segmentIndex = pendingBreakSegmentIndex; + cursor.graphemeIndex = 0; + return pendingBreakPaintWidth; + } + cursor.segmentIndex = lineEndSegmentIndex; + cursor.graphemeIndex = lineEndGraphemeIndex; + return lineW; + } + lineW += w; + lineEndSegmentIndex = i + 1; + lineEndGraphemeIndex = 0; + if (breakAfter) { + pendingBreakSegmentIndex = i + 1; + pendingBreakPaintWidth = lineW - w; + } + } + if (!hasContent) + return null; + cursor.segmentIndex = lineEndSegmentIndex; + cursor.graphemeIndex = lineEndGraphemeIndex; + return lineW; +} +export function layoutNextLineRange(prepared, start, maxWidth) { + const end = { + segmentIndex: start.segmentIndex, + graphemeIndex: start.graphemeIndex, + }; + const chunkIndex = normalizeLineStartChunkIndex(prepared, end); + if (chunkIndex < 0) + return null; + const lineStartSegmentIndex = end.segmentIndex; + const lineStartGraphemeIndex = end.graphemeIndex; + const width = prepared.simpleLineWalkFastPath + ? stepPreparedSimpleLineGeometry(prepared, end, maxWidth) + : stepPreparedChunkLineGeometry(prepared, end, chunkIndex, maxWidth); + if (width === null) + return null; + return { + startSegmentIndex: lineStartSegmentIndex, + startGraphemeIndex: lineStartGraphemeIndex, + endSegmentIndex: end.segmentIndex, + endGraphemeIndex: end.graphemeIndex, + width, + }; +} +export function stepPreparedLineGeometry(prepared, cursor, maxWidth) { + const chunkIndex = normalizeLineStartChunkIndex(prepared, cursor); + if (chunkIndex < 0) + return null; + if (prepared.simpleLineWalkFastPath) { + return stepPreparedSimpleLineGeometry(prepared, cursor, maxWidth); + } + return stepPreparedChunkLineGeometry(prepared, cursor, chunkIndex, maxWidth); +} +export function measurePreparedLineGeometry(prepared, maxWidth) { + if (prepared.widths.length === 0) { + return { + lineCount: 0, + maxLineWidth: 0, + }; + } + const cursor = { + segmentIndex: 0, + graphemeIndex: 0, + }; + let lineCount = 0; + let maxLineWidth = 0; + if (!prepared.simpleLineWalkFastPath) { + let chunkIndex = normalizeLineStartChunkIndex(prepared, cursor); + while (chunkIndex >= 0) { + const lineWidth = stepPreparedChunkLineGeometry(prepared, cursor, chunkIndex, maxWidth); + if (lineWidth === null) { + return { + lineCount, + maxLineWidth, + }; + } + lineCount++; + if (lineWidth > maxLineWidth) + maxLineWidth = lineWidth; + chunkIndex = normalizeLineStartChunkIndexFromHint(prepared, chunkIndex, cursor); + } + return { + lineCount, + maxLineWidth, + }; + } + while (true) { + const lineWidth = stepPreparedLineGeometry(prepared, cursor, maxWidth); + if (lineWidth === null) { + return { + lineCount, + maxLineWidth, + }; + } + lineCount++; + if (lineWidth > maxLineWidth) + maxLineWidth = lineWidth; + } +} diff --git a/dist/measurement.d.ts b/dist/measurement.d.ts new file mode 100644 index 00000000..c66bb505 --- /dev/null +++ b/dist/measurement.d.ts @@ -0,0 +1,29 @@ +export type SegmentMetrics = { + width: number; + containsCJK: boolean; + emojiCount?: number; + graphemeWidths?: number[] | null; + graphemePrefixWidths?: number[] | null; +}; +export type EngineProfile = { + lineFitEpsilon: number; + carryCJKAfterClosingQuote: boolean; + preferPrefixWidthsForBreakableRuns: boolean; + preferEarlySoftHyphenBreak: boolean; +}; +export declare function getMeasureContext(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +export declare function getSegmentMetricCache(font: string): Map; +export declare function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics; +export declare function getEngineProfile(): EngineProfile; +export declare function resolveFont(font: string): string; +export declare function parseFontSize(font: string): number; +export declare function textMayContainEmoji(text: string): boolean; +export declare function getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number; +export declare function getSegmentGraphemeWidths(seg: string, metrics: SegmentMetrics, cache: Map, emojiCorrection: number): number[] | null; +export declare function getSegmentGraphemePrefixWidths(seg: string, metrics: SegmentMetrics, cache: Map, emojiCorrection: number): number[] | null; +export declare function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): { + cache: Map; + fontSize: number; + emojiCorrection: number; +}; +export declare function clearMeasurementCaches(): void; diff --git a/dist/measurement.js b/dist/measurement.js new file mode 100644 index 00000000..181bedf7 --- /dev/null +++ b/dist/measurement.js @@ -0,0 +1,210 @@ +import { isCJK } from './analysis.js'; +let measureContext = null; +const segmentMetricCaches = new Map(); +let cachedEngineProfile = null; +const emojiPresentationRe = /\p{Emoji_Presentation}/u; +const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u; +let sharedGraphemeSegmenter = null; +const emojiCorrectionCache = new Map(); +export function getMeasureContext() { + if (measureContext !== null) + return measureContext; + if (typeof OffscreenCanvas !== 'undefined') { + measureContext = new OffscreenCanvas(1, 1).getContext('2d'); + return measureContext; + } + if (typeof document !== 'undefined') { + measureContext = document.createElement('canvas').getContext('2d'); + return measureContext; + } + throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.'); +} +export function getSegmentMetricCache(font) { + let cache = segmentMetricCaches.get(font); + if (!cache) { + cache = new Map(); + segmentMetricCaches.set(font, cache); + } + return cache; +} +export function getSegmentMetrics(seg, cache) { + let metrics = cache.get(seg); + if (metrics === undefined) { + const ctx = getMeasureContext(); + metrics = { + width: ctx.measureText(seg).width, + containsCJK: isCJK(seg), + }; + cache.set(seg, metrics); + } + return metrics; +} +export function getEngineProfile() { + if (cachedEngineProfile !== null) + return cachedEngineProfile; + if (typeof navigator === 'undefined') { + cachedEngineProfile = { + lineFitEpsilon: 0.005, + carryCJKAfterClosingQuote: false, + preferPrefixWidthsForBreakableRuns: false, + preferEarlySoftHyphenBreak: false, + }; + return cachedEngineProfile; + } + const ua = navigator.userAgent; + const vendor = navigator.vendor; + const isSafari = vendor === 'Apple Computer, Inc.' && + ua.includes('Safari/') && + !ua.includes('Chrome/') && + !ua.includes('Chromium/') && + !ua.includes('CriOS/') && + !ua.includes('FxiOS/') && + !ua.includes('EdgiOS/'); + const isChromium = ua.includes('Chrome/') || + ua.includes('Chromium/') || + ua.includes('CriOS/') || + ua.includes('Edg/'); + cachedEngineProfile = { + lineFitEpsilon: isSafari ? 1 / 64 : 0.005, + carryCJKAfterClosingQuote: isChromium, + preferPrefixWidthsForBreakableRuns: isSafari, + preferEarlySoftHyphenBreak: isSafari, + }; + return cachedEngineProfile; +} +// Relative font unit resolution for canvas. +// +// Canvas ctx.font silently ignores rem/em (spec limitation — canvas can exist +// without a document root). Callers using CSS-derived font strings like +// "0.9rem Inter" will get the wrong measurement unless we resolve to px first. +// +// rem: resolved against document root font-size (one cached DOM read). +// em: treated as rem (no parent context available in canvas). +// Already-absolute fonts pass through unchanged with no allocation. +let cachedRootFontSize = null; +const resolvedFontCache = new Map(); +const relativeFontRe = /(\d+(?:\.\d+)?)\s*(rem|em)/; +function getRootFontSize() { + if (cachedRootFontSize !== null) + return cachedRootFontSize; + if (typeof document !== 'undefined' && document.documentElement) { + cachedRootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize); + } + return cachedRootFontSize ?? 16; +} +export function resolveFont(font) { + let resolved = resolvedFontCache.get(font); + if (resolved !== undefined) + return resolved; + resolved = font.replace(relativeFontRe, (_, size) => { + return `${parseFloat(size) * getRootFontSize()}px`; + }); + resolvedFontCache.set(font, resolved); + return resolved; +} +export function parseFontSize(font) { + const resolved = resolveFont(font); + const m = resolved.match(/(\d+(?:\.\d+)?)\s*px/); + return m ? parseFloat(m[1]) : 16; +} +function getSharedGraphemeSegmenter() { + if (sharedGraphemeSegmenter === null) { + sharedGraphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + } + return sharedGraphemeSegmenter; +} +function isEmojiGrapheme(g) { + return emojiPresentationRe.test(g) || g.includes('\uFE0F'); +} +export function textMayContainEmoji(text) { + return maybeEmojiRe.test(text); +} +function getEmojiCorrection(font, fontSize) { + let correction = emojiCorrectionCache.get(font); + if (correction !== undefined) + return correction; + const ctx = getMeasureContext(); + ctx.font = font; + const canvasW = ctx.measureText('\u{1F600}').width; + correction = 0; + if (canvasW > fontSize + 0.5 && + typeof document !== 'undefined' && + document.body !== null) { + const span = document.createElement('span'); + span.style.font = font; + span.style.display = 'inline-block'; + span.style.visibility = 'hidden'; + span.style.position = 'absolute'; + span.textContent = '\u{1F600}'; + document.body.appendChild(span); + const domW = span.getBoundingClientRect().width; + document.body.removeChild(span); + if (canvasW - domW > 0.5) { + correction = canvasW - domW; + } + } + emojiCorrectionCache.set(font, correction); + return correction; +} +function countEmojiGraphemes(text) { + let count = 0; + const graphemeSegmenter = getSharedGraphemeSegmenter(); + for (const g of graphemeSegmenter.segment(text)) { + if (isEmojiGrapheme(g.segment)) + count++; + } + return count; +} +function getEmojiCount(seg, metrics) { + if (metrics.emojiCount === undefined) { + metrics.emojiCount = countEmojiGraphemes(seg); + } + return metrics.emojiCount; +} +export function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) { + if (emojiCorrection === 0) + return metrics.width; + return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection; +} +export function getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection) { + if (metrics.graphemeWidths !== undefined) + return metrics.graphemeWidths; + const widths = []; + const graphemeSegmenter = getSharedGraphemeSegmenter(); + for (const gs of graphemeSegmenter.segment(seg)) { + const graphemeMetrics = getSegmentMetrics(gs.segment, cache); + widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection)); + } + metrics.graphemeWidths = widths.length > 1 ? widths : null; + return metrics.graphemeWidths; +} +export function getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection) { + if (metrics.graphemePrefixWidths !== undefined) + return metrics.graphemePrefixWidths; + const prefixWidths = []; + const graphemeSegmenter = getSharedGraphemeSegmenter(); + let prefix = ''; + for (const gs of graphemeSegmenter.segment(seg)) { + prefix += gs.segment; + const prefixMetrics = getSegmentMetrics(prefix, cache); + prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection)); + } + metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null; + return metrics.graphemePrefixWidths; +} +export function getFontMeasurementState(font, needsEmojiCorrection) { + const resolved = resolveFont(font); + const ctx = getMeasureContext(); + ctx.font = resolved; + const cache = getSegmentMetricCache(resolved); + const fontSize = parseFontSize(resolved); + const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(resolved, fontSize) : 0; + return { cache, fontSize, emojiCorrection }; +} +export function clearMeasurementCaches() { + segmentMetricCaches.clear(); + emojiCorrectionCache.clear(); + resolvedFontCache.clear(); + cachedRootFontSize = null; + sharedGraphemeSegmenter = null; +} diff --git a/dist/rich-inline.d.ts b/dist/rich-inline.d.ts new file mode 100644 index 00000000..6ddd4c7f --- /dev/null +++ b/dist/rich-inline.d.ts @@ -0,0 +1,51 @@ +import { type LayoutCursor } from './layout.js'; +declare const preparedRichInlineBrand: unique symbol; +export type RichInlineItem = { + text: string; + font: string; + break?: 'normal' | 'never'; + extraWidth?: number; +}; +export type PreparedRichInline = { + readonly [preparedRichInlineBrand]: true; +}; +export type RichInlineCursor = { + itemIndex: number; + segmentIndex: number; + graphemeIndex: number; +}; +export type RichInlineFragment = { + itemIndex: number; + text: string; + gapBefore: number; + occupiedWidth: number; + start: LayoutCursor; + end: LayoutCursor; +}; +export type RichInlineFragmentRange = { + itemIndex: number; + gapBefore: number; + occupiedWidth: number; + start: LayoutCursor; + end: LayoutCursor; +}; +export type RichInlineLine = { + fragments: RichInlineFragment[]; + width: number; + end: RichInlineCursor; +}; +export type RichInlineLineRange = { + fragments: RichInlineFragmentRange[]; + width: number; + end: RichInlineCursor; +}; +export type RichInlineStats = { + lineCount: number; + maxLineWidth: number; +}; +export declare function prepareRichInline(items: RichInlineItem[]): PreparedRichInline; +export declare function layoutNextRichInlineLineRange(prepared: PreparedRichInline, maxWidth: number, start?: RichInlineCursor): RichInlineLineRange | null; +export declare function materializeRichInlineLineRange(prepared: PreparedRichInline, line: RichInlineLineRange): RichInlineLine; +export declare function walkRichInlineLineRanges(prepared: PreparedRichInline, maxWidth: number, onLine: (line: RichInlineLineRange) => void): number; +export declare function measureRichInlineStats(prepared: PreparedRichInline, maxWidth: number): RichInlineStats; +export {}; diff --git a/dist/rich-inline.js b/dist/rich-inline.js new file mode 100644 index 00000000..9039e2ad --- /dev/null +++ b/dist/rich-inline.js @@ -0,0 +1,401 @@ +import { materializeLineRange, measureNaturalWidth, prepareWithSegments, } from './layout.js'; +import { layoutNextLineRange as stepPreparedLineRange, stepPreparedLineGeometry, } from './line-break.js'; +const COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+/; +const LEADING_COLLAPSIBLE_BOUNDARY_RE = /^[ \t\n\f\r]+/; +const TRAILING_COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+$/; +const EMPTY_LAYOUT_CURSOR = { segmentIndex: 0, graphemeIndex: 0 }; +const RICH_INLINE_START_CURSOR = { + itemIndex: 0, + segmentIndex: 0, + graphemeIndex: 0, +}; +function getInternalPreparedRichInline(prepared) { + return prepared; +} +function cloneCursor(cursor) { + return { + segmentIndex: cursor.segmentIndex, + graphemeIndex: cursor.graphemeIndex, + }; +} +function isLineStartCursor(cursor) { + return cursor.segmentIndex === 0 && cursor.graphemeIndex === 0; +} +function getCollapsedSpaceWidth(font, cache) { + const cached = cache.get(font); + if (cached !== undefined) + return cached; + const joinedWidth = measureNaturalWidth(prepareWithSegments('A A', font)); + const compactWidth = measureNaturalWidth(prepareWithSegments('AA', font)); + const collapsedWidth = Math.max(0, joinedWidth - compactWidth); + cache.set(font, collapsedWidth); + return collapsedWidth; +} +function prepareWholeItemLine(prepared) { + const line = stepPreparedLineRange(prepared, EMPTY_LAYOUT_CURSOR, Number.POSITIVE_INFINITY); + if (line === null) + return null; + return { + endGraphemeIndex: line.endGraphemeIndex, + endSegmentIndex: line.endSegmentIndex, + width: line.width, + }; +} +function endsInsideFirstSegment(segmentIndex, graphemeIndex) { + return segmentIndex === 0 && graphemeIndex > 0; +} +export function prepareRichInline(items) { + const preparedItems = []; + const itemsBySourceItemIndex = Array.from({ length: items.length }); + const collapsedSpaceWidthCache = new Map(); + let pendingGapWidth = 0; + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const hasLeadingWhitespace = LEADING_COLLAPSIBLE_BOUNDARY_RE.test(item.text); + const hasTrailingWhitespace = TRAILING_COLLAPSIBLE_BOUNDARY_RE.test(item.text); + const trimmedText = item.text + .replace(LEADING_COLLAPSIBLE_BOUNDARY_RE, '') + .replace(TRAILING_COLLAPSIBLE_BOUNDARY_RE, ''); + if (trimmedText.length === 0) { + if (COLLAPSIBLE_BOUNDARY_RE.test(item.text) && pendingGapWidth === 0) { + pendingGapWidth = getCollapsedSpaceWidth(item.font, collapsedSpaceWidthCache); + } + continue; + } + const gapBefore = pendingGapWidth > 0 + ? pendingGapWidth + : hasLeadingWhitespace + ? getCollapsedSpaceWidth(item.font, collapsedSpaceWidthCache) + : 0; + const prepared = prepareWithSegments(trimmedText, item.font); + const wholeLine = prepareWholeItemLine(prepared); + if (wholeLine === null) { + pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.font, collapsedSpaceWidthCache) : 0; + continue; + } + const preparedItem = { + break: item.break ?? 'normal', + endGraphemeIndex: wholeLine.endGraphemeIndex, + endSegmentIndex: wholeLine.endSegmentIndex, + extraWidth: item.extraWidth ?? 0, + gapBefore, + naturalWidth: wholeLine.width, + prepared, + sourceItemIndex: index, + }; + preparedItems.push(preparedItem); + itemsBySourceItemIndex[index] = preparedItem; + pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.font, collapsedSpaceWidthCache) : 0; + } + return { + items: preparedItems, + itemsBySourceItemIndex, + }; +} +function stepRichInlineLine(flow, maxWidth, cursor, collectFragment) { + if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) + return null; + const safeWidth = Math.max(1, maxWidth); + let lineWidth = 0; + let remainingWidth = safeWidth; + let itemIndex = cursor.itemIndex; + const textCursor = { + segmentIndex: cursor.segmentIndex, + graphemeIndex: cursor.graphemeIndex, + }; + lineLoop: while (itemIndex < flow.items.length) { + const item = flow.items[itemIndex]; + if (!isLineStartCursor(textCursor) && + textCursor.segmentIndex === item.endSegmentIndex && + textCursor.graphemeIndex === item.endGraphemeIndex) { + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + const gapBefore = lineWidth === 0 ? 0 : item.gapBefore; + const atItemStart = isLineStartCursor(textCursor); + if (item.break === 'never') { + if (!atItemStart) { + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + const occupiedWidth = item.naturalWidth + item.extraWidth; + const totalWidth = gapBefore + occupiedWidth; + if (lineWidth > 0 && totalWidth > remainingWidth) + break lineLoop; + collectFragment?.(item, gapBefore, occupiedWidth, cloneCursor(EMPTY_LAYOUT_CURSOR), { + segmentIndex: item.endSegmentIndex, + graphemeIndex: item.endGraphemeIndex, + }); + lineWidth += totalWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + const reservedWidth = gapBefore + item.extraWidth; + if (lineWidth > 0 && reservedWidth >= remainingWidth) + break lineLoop; + if (atItemStart) { + const totalWidth = reservedWidth + item.naturalWidth; + if (totalWidth <= remainingWidth) { + collectFragment?.(item, gapBefore, item.naturalWidth + item.extraWidth, cloneCursor(EMPTY_LAYOUT_CURSOR), { + segmentIndex: item.endSegmentIndex, + graphemeIndex: item.endGraphemeIndex, + }); + lineWidth += totalWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + } + const availableWidth = Math.max(1, remainingWidth - reservedWidth); + const line = stepPreparedLineRange(item.prepared, textCursor, availableWidth); + if (line === null) { + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + if (textCursor.segmentIndex === line.endSegmentIndex && + textCursor.graphemeIndex === line.endGraphemeIndex) { + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + // If the only thing we can fit after paying the boundary gap is a partial + // slice of the item's first segment, prefer wrapping before the item so we + // keep whole-word-style boundaries when they exist. But once the current + // line can consume a real breakable unit from the item, stay greedy and + // keep filling the line. + if (lineWidth > 0 && + atItemStart && + gapBefore > 0 && + endsInsideFirstSegment(line.endSegmentIndex, line.endGraphemeIndex)) { + const freshLine = stepPreparedLineRange(item.prepared, EMPTY_LAYOUT_CURSOR, Math.max(1, safeWidth - item.extraWidth)); + if (freshLine !== null && + (freshLine.endSegmentIndex > line.endSegmentIndex || + (freshLine.endSegmentIndex === line.endSegmentIndex && + freshLine.endGraphemeIndex > line.endGraphemeIndex))) { + break lineLoop; + } + } + collectFragment?.(item, gapBefore, line.width + item.extraWidth, cloneCursor(textCursor), { + segmentIndex: line.endSegmentIndex, + graphemeIndex: line.endGraphemeIndex, + }); + lineWidth += gapBefore + line.width + item.extraWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + if (line.endSegmentIndex === item.endSegmentIndex && + line.endGraphemeIndex === item.endGraphemeIndex) { + itemIndex++; + textCursor.segmentIndex = 0; + textCursor.graphemeIndex = 0; + continue; + } + textCursor.segmentIndex = line.endSegmentIndex; + textCursor.graphemeIndex = line.endGraphemeIndex; + break; + } + if (lineWidth === 0) + return null; + cursor.itemIndex = itemIndex; + cursor.segmentIndex = textCursor.segmentIndex; + cursor.graphemeIndex = textCursor.graphemeIndex; + return lineWidth; +} +function stepRichInlineLineStats(flow, maxWidth, cursor) { + if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) + return null; + const safeWidth = Math.max(1, maxWidth); + let lineWidth = 0; + let remainingWidth = safeWidth; + let itemIndex = cursor.itemIndex; + lineLoop: while (itemIndex < flow.items.length) { + const item = flow.items[itemIndex]; + if (!isLineStartCursor(cursor) && + cursor.segmentIndex === item.endSegmentIndex && + cursor.graphemeIndex === item.endGraphemeIndex) { + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + const gapBefore = lineWidth === 0 ? 0 : item.gapBefore; + const atItemStart = isLineStartCursor(cursor); + if (item.break === 'never') { + if (!atItemStart) { + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + const occupiedWidth = item.naturalWidth + item.extraWidth; + const totalWidth = gapBefore + occupiedWidth; + if (lineWidth > 0 && totalWidth > remainingWidth) + break lineLoop; + lineWidth += totalWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + const reservedWidth = gapBefore + item.extraWidth; + if (lineWidth > 0 && reservedWidth >= remainingWidth) + break lineLoop; + if (atItemStart) { + const totalWidth = reservedWidth + item.naturalWidth; + if (totalWidth <= remainingWidth) { + lineWidth += totalWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + } + const availableWidth = Math.max(1, remainingWidth - reservedWidth); + const lineEnd = { + segmentIndex: cursor.segmentIndex, + graphemeIndex: cursor.graphemeIndex, + }; + const lineWidthForItem = stepPreparedLineGeometry(item.prepared, lineEnd, availableWidth); + if (lineWidthForItem === null) { + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + if (cursor.segmentIndex === lineEnd.segmentIndex && cursor.graphemeIndex === lineEnd.graphemeIndex) { + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + if (lineWidth > 0 && + atItemStart && + gapBefore > 0 && + endsInsideFirstSegment(lineEnd.segmentIndex, lineEnd.graphemeIndex)) { + const freshLineEnd = { + segmentIndex: 0, + graphemeIndex: 0, + }; + const freshLineWidth = stepPreparedLineGeometry(item.prepared, freshLineEnd, Math.max(1, safeWidth - item.extraWidth)); + if (freshLineWidth !== null && + (freshLineEnd.segmentIndex > lineEnd.segmentIndex || + (freshLineEnd.segmentIndex === lineEnd.segmentIndex && + freshLineEnd.graphemeIndex > lineEnd.graphemeIndex))) { + break lineLoop; + } + } + lineWidth += gapBefore + lineWidthForItem + item.extraWidth; + remainingWidth = Math.max(0, safeWidth - lineWidth); + if (lineEnd.segmentIndex === item.endSegmentIndex && lineEnd.graphemeIndex === item.endGraphemeIndex) { + itemIndex++; + cursor.segmentIndex = 0; + cursor.graphemeIndex = 0; + continue; + } + cursor.segmentIndex = lineEnd.segmentIndex; + cursor.graphemeIndex = lineEnd.graphemeIndex; + break; + } + if (lineWidth === 0) + return null; + cursor.itemIndex = itemIndex; + return lineWidth; +} +export function layoutNextRichInlineLineRange(prepared, maxWidth, start = RICH_INLINE_START_CURSOR) { + const flow = getInternalPreparedRichInline(prepared); + const end = { + itemIndex: start.itemIndex, + segmentIndex: start.segmentIndex, + graphemeIndex: start.graphemeIndex, + }; + const fragments = []; + const width = stepRichInlineLine(flow, maxWidth, end, (item, gapBefore, occupiedWidth, fragmentStart, fragmentEnd) => { + fragments.push({ + itemIndex: item.sourceItemIndex, + gapBefore, + occupiedWidth, + start: fragmentStart, + end: fragmentEnd, + }); + }); + if (width === null) + return null; + return { + fragments, + width, + end, + }; +} +function materializeFragmentText(item, fragment) { + const line = materializeLineRange(item.prepared, { + width: fragment.occupiedWidth - item.extraWidth, + start: fragment.start, + end: fragment.end, + }); + return line.text; +} +// Bridge from cheap range walking to full fragment text. Lets callers do +// shrinkwrap/virtualization/probing work first, then only pay for text on the +// lines they actually render. +export function materializeRichInlineLineRange(prepared, line) { + const flow = getInternalPreparedRichInline(prepared); + return { + fragments: line.fragments.map(fragment => { + const item = flow.itemsBySourceItemIndex[fragment.itemIndex]; + if (item === undefined) + throw new Error('Missing rich-text inline item for fragment'); + return { + ...fragment, + text: materializeFragmentText(item, fragment), + }; + }), + width: line.width, + end: line.end, + }; +} +export function walkRichInlineLineRanges(prepared, maxWidth, onLine) { + let lineCount = 0; + let cursor = RICH_INLINE_START_CURSOR; + while (true) { + const line = layoutNextRichInlineLineRange(prepared, maxWidth, cursor); + if (line === null) + return lineCount; + onLine(line); + lineCount++; + cursor = line.end; + } +} +export function measureRichInlineStats(prepared, maxWidth) { + const flow = getInternalPreparedRichInline(prepared); + let lineCount = 0; + let maxLineWidth = 0; + const cursor = { + itemIndex: 0, + segmentIndex: 0, + graphemeIndex: 0, + }; + while (true) { + const lineWidth = stepRichInlineLineStats(flow, maxWidth, cursor); + if (lineWidth === null) { + return { + lineCount, + maxLineWidth, + }; + } + lineCount++; + if (lineWidth > maxLineWidth) + maxLineWidth = lineWidth; + } +}