From bba4ed2395295df40ab939aec1be0b74aa4c5e99 Mon Sep 17 00:00:00 2001 From: ishtihoss Date: Mon, 30 Mar 2026 09:58:11 -0700 Subject: [PATCH] Add intrinsic sizing APIs: minContentWidth and maxContentWidth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS-standard intrinsic width queries over prepared text, answering the open design question in TODO.md. Both are pure arithmetic over cached segment widths — no DOM reads, no canvas calls — consistent with the layout() hot-path philosophy. minContentWidth: narrowest container where no word needs grapheme-level breaking. Accounts for soft-hyphen discretionary width at break points. maxContentWidth: single-line width with no soft wrapping, excluding trailing whitespace. For pre-wrap text with hard breaks, returns the widest chunk. Handles tab-stop advances. Co-Authored-By: ishtihoss --- README.md | 2 + src/layout.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++ src/layout.ts | 68 ++++++++++++++++++++++++++++++++ src/line-break.ts | 2 +- 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee00d5cf..1cfe2511 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ type LayoutCursor = { Other helpers: ```ts +minContentWidth(prepared: PreparedText): number // narrowest container width where no word needs grapheme-level breaking (CSS min-content). Pure arithmetic over cached widths +maxContentWidth(prepared: PreparedText): number // single-line width when no soft wrapping occurs, excluding trailing whitespace (CSS max-content). For pre-wrap text with \n hard breaks, returns the widest chunk clearCache(): void // clears Pretext's shared internal caches used by prepare() and prepareWithSegments(). Useful if your app cycles through many different fonts or text variants and you want to release the accumulated cache setLocale(locale?: string): void // optional (by default we use the current locale). Sets locale for future prepare() and prepareWithSegments(). Internally, it also calls clearCache(). Setting a new locale doesn't affect existing prepare() and prepareWithSegments() states (no mutations to them) ``` diff --git a/src/layout.test.ts b/src/layout.test.ts index 3b5d01bb..eb118016 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -17,6 +17,8 @@ let layout: LayoutModule['layout'] let layoutWithLines: LayoutModule['layoutWithLines'] let layoutNextLine: LayoutModule['layoutNextLine'] let walkLineRanges: LayoutModule['walkLineRanges'] +let minContentWidth: LayoutModule['minContentWidth'] +let maxContentWidth: LayoutModule['maxContentWidth'] let clearCache: LayoutModule['clearCache'] let setLocale: LayoutModule['setLocale'] let countPreparedLines: LineBreakModule['countPreparedLines'] @@ -107,6 +109,8 @@ beforeAll(async () => { layoutWithLines, layoutNextLine, walkLineRanges, + minContentWidth, + maxContentWidth, clearCache, setLocale, } = mod) @@ -625,3 +629,95 @@ describe('layout invariants', () => { } }) }) + +describe('intrinsic width invariants', () => { + test('empty text returns 0 for both', () => { + const prepared = prepare('', FONT) + expect(minContentWidth(prepared)).toBe(0) + expect(maxContentWidth(prepared)).toBe(0) + }) + + test('whitespace-only text returns 0 for both', () => { + const prepared = prepare(' \t\n ', FONT) + expect(minContentWidth(prepared)).toBe(0) + expect(maxContentWidth(prepared)).toBe(0) + }) + + test('single word: min equals max equals word width', () => { + const prepared = prepareWithSegments('Superlongword', FONT) + const min = minContentWidth(prepared) + const max = maxContentWidth(prepared) + expect(min).toBe(prepared.widths[0]!) + expect(max).toBe(prepared.widths[0]!) + expect(min).toBe(max) + }) + + test('multiple words: min is the widest word', () => { + const prepared = prepareWithSegments('hi Superlongword ok', FONT) + const min = minContentWidth(prepared) + expect(min).toBe(prepared.widths[2]!) // 'Superlongword' is widest + }) + + test('multiple words: max is the full single-line width', () => { + const prepared = prepareWithSegments('hello world', FONT) + const max = maxContentWidth(prepared) + expect(max).toBe(prepared.widths[0]! + prepared.widths[1]! + prepared.widths[2]!) + }) + + test('max excludes trailing whitespace', () => { + const withSpace = prepare('hello ', FONT) + const without = prepare('hello', FONT) + expect(maxContentWidth(withSpace)).toBe(maxContentWidth(without)) + }) + + test('min accounts for soft hyphen discretionary width', () => { + const prepared = prepareWithSegments('trans\u00ADatlantic', FONT) + const min = minContentWidth(prepared) + const transWithHyphen = prepared.widths[0]! + prepared.discretionaryHyphenWidth + const atlantic = prepared.widths[2]! + expect(min).toBe(Math.max(transWithHyphen, atlantic)) + }) + + test('max excludes invisible soft hyphens', () => { + const prepared = prepareWithSegments('trans\u00ADatlantic', FONT) + const max = maxContentWidth(prepared) + expect(max).toBe(prepared.widths[0]! + prepared.widths[2]!) + }) + + test('layout at maxContentWidth fits on one line', () => { + const prepared = prepare('The quick brown fox jumps over the lazy dog', FONT) + const max = maxContentWidth(prepared) + expect(layout(prepared, max, LINE_HEIGHT).lineCount).toBe(1) + }) + + test('layout at minContentWidth produces no grapheme-level word breaks', () => { + const prepared = prepareWithSegments('The quick brown fox', FONT) + const min = minContentWidth(prepared) + const result = layoutWithLines(prepared, min, LINE_HEIGHT) + for (const line of result.lines) { + expect(line.start.graphemeIndex).toBe(0) + expect(line.end.graphemeIndex).toBe(0) + } + }) + + test('pre-wrap hard breaks: max is the widest chunk', () => { + const prepared = prepareWithSegments('short\nlongerword', FONT, { whiteSpace: 'pre-wrap' }) + const max = maxContentWidth(prepared) + expect(max).toBe(Math.max(prepared.widths[0]!, prepared.widths[2]!)) + }) + + test('works with both prepare and prepareWithSegments', () => { + const plain = prepare('hello world', FONT) + const rich = prepareWithSegments('hello world', FONT) + expect(minContentWidth(plain)).toBe(minContentWidth(rich)) + expect(maxContentWidth(plain)).toBe(maxContentWidth(rich)) + }) + + test('CJK: min is the widest grapheme unit', () => { + const prepared = prepareWithSegments('中文测试', FONT) + const min = minContentWidth(prepared) + const max = maxContentWidth(prepared) + expect(min).toBeGreaterThan(0) + expect(max).toBeGreaterThan(min) + }) +}) diff --git a/src/layout.ts b/src/layout.ts index 465a0673..c55da30b 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -59,6 +59,7 @@ import { } from './measurement.js' import { countPreparedLines, + getTabAdvance, layoutNextLineRange as stepPreparedLineRange, walkPreparedLines, type InternalLayoutLine, @@ -704,6 +705,73 @@ export function layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: nu return { lineCount, height: lineCount * lineHeight, lines } } +// Narrowest container width where no word needs grapheme-level breaking. +// Pure arithmetic over cached segment widths — no DOM reads, no canvas calls. +// Matches CSS min-content behavior for the targeted white-space / overflow-wrap config. +export function minContentWidth(prepared: PreparedText): number { + const p = getInternalPrepared(prepared) + let max = 0 + for (let i = 0; i < p.widths.length; i++) { + const kind = p.kinds[i]! + if ( + kind === 'space' || + kind === 'preserved-space' || + kind === 'tab' || + kind === 'zero-width-break' || + kind === 'soft-hyphen' || + kind === 'hard-break' + ) { + continue + } + let w = p.widths[i]! + if (i + 1 < p.kinds.length && p.kinds[i + 1] === 'soft-hyphen') { + w += p.discretionaryHyphenWidth + } + if (w > max) max = w + } + return max +} + +// Single-line width when no soft wrapping occurs, excluding trailing whitespace. +// For pre-wrap text with hard breaks, returns the widest hard-break-separated chunk. +// Pure arithmetic over cached segment widths — no DOM reads, no canvas calls. +// Matches CSS max-content behavior for the targeted white-space / overflow-wrap config. +export function maxContentWidth(prepared: PreparedText): number { + const p = getInternalPrepared(prepared) + if (p.widths.length === 0) return 0 + + let maxWidth = 0 + let lineWidth = 0 + let contentWidth = 0 + + for (let i = 0; i < p.widths.length; i++) { + const kind = p.kinds[i]! + + if (kind === 'hard-break') { + if (contentWidth > maxWidth) maxWidth = contentWidth + lineWidth = 0 + contentWidth = 0 + continue + } + + if (kind === 'soft-hyphen') continue + + if (kind === 'tab') { + lineWidth += getTabAdvance(lineWidth, p.tabStopAdvance) + continue + } + + lineWidth += p.widths[i]! + + if (kind !== 'space' && kind !== 'preserved-space' && kind !== 'zero-width-break') { + contentWidth = lineWidth + } + } + + if (contentWidth > maxWidth) maxWidth = contentWidth + return maxWidth +} + export function clearCache(): void { clearAnalysisCaches() sharedGraphemeSegmenter = null diff --git a/src/line-break.ts b/src/line-break.ts index 57fa1131..c5742544 100644 --- a/src/line-break.ts +++ b/src/line-break.ts @@ -45,7 +45,7 @@ function isSimpleCollapsibleSpace(kind: SegmentBreakKind): boolean { return kind === 'space' } -function getTabAdvance(lineWidth: number, tabStopAdvance: number): number { +export function getTabAdvance(lineWidth: number, tabStopAdvance: number): number { if (tabStopAdvance <= 0) return 0 const remainder = lineWidth % tabStopAdvance