Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
96 changes: 96 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -107,6 +109,8 @@ beforeAll(async () => {
layoutWithLines,
layoutNextLine,
walkLineRanges,
minContentWidth,
maxContentWidth,
clearCache,
setLocale,
} = mod)
Expand Down Expand Up @@ -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)
})
})
68 changes: 68 additions & 0 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from './measurement.js'
import {
countPreparedLines,
getTabAdvance,
layoutNextLineRange as stepPreparedLineRange,
walkPreparedLines,
type InternalLayoutLine,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/line-break.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down