diff --git a/README.md b/README.md index ce0a816..6bccfd8 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ This usage allows rendering to canvas, SVG, WebGL and (eventually) server-side. For hyphenation in manual layout, insert soft hyphens before `prepare()` / `prepareWithSegments()`. Pretext treats them as optional break points: unchosen soft hyphens stay invisible, while chosen breaks materialize as a trailing `-`. For mixed-language or user-generated app text, prefer conservative, locale-aware insertion over aggressive pattern hyphenation. Automatic hyphenation is not built in today. -If your manual layout needs a small helper for rich-text inline flow, code spans, mentions, chips, and browser-like boundary whitespace collapse, there is a helper at `@chenglou/pretext/rich-inline`. It stays inline-only and `white-space: normal`-only on purpose: +If your manual layout needs a small helper for rich-text inline flow, code spans, mentions, chips, and browser-like boundary whitespace collapse, there is a helper at `@chenglou/pretext/rich-inline`. By default it uses `white-space: normal` boundary collapsing; pass `{ whiteSpace: 'pre-wrap' }` to preserve leading/trailing spaces on each item instead: ```ts import { materializeRichInlineLineRange, prepareRichInline, walkRichInlineLineRanges } from '@chenglou/pretext/rich-inline' @@ -117,7 +117,7 @@ It is intentionally narrow: - raw inline text in, including boundary spaces - caller-owned `extraWidth` for pill chrome - `break: 'never'` for atomic items like chips and mentions -- `white-space: normal` only +- `white-space: normal` (default) or `pre-wrap` - not a nested markup tree and not a general CSS inline formatting engine ### API Glossary @@ -161,7 +161,7 @@ type LayoutCursor = { Helper for rich-text inline flow: ```ts -prepareRichInline(items: RichInlineItem[]): PreparedRichInline // compile raw inline items with their original text. The compiler owns cross-item collapsed whitespace and caches each item's natural width +prepareRichInline(items: RichInlineItem[], options?: { whiteSpace?: 'normal' | 'pre-wrap' }): PreparedRichInline // compile raw inline items with their original text. The compiler owns cross-item collapsed whitespace and caches each item's natural width. Pass `{ whiteSpace: 'pre-wrap' }` to preserve leading/trailing spaces instead of collapsing them into inter-item gaps layoutNextRichInlineLineRange(prepared: PreparedRichInline, maxWidth: number, start?: RichInlineCursor): RichInlineLineRange | null // stream one line of rich-text inline flow at a time without building fragment text strings walkRichInlineLineRanges(prepared: PreparedRichInline, maxWidth: number, onLine: (line: RichInlineLineRange) => void): number // non-materializing line walker for rich-text inline flow shrinkwrap/stats work materializeRichInlineLineRange(prepared: PreparedRichInline, line: RichInlineLineRange): RichInlineLine // turns one previously computed rich-inline line range back into full fragment text diff --git a/src/layout.test.ts b/src/layout.test.ts index 0c13c82..368d7d5 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -984,6 +984,67 @@ describe('rich-inline invariants', () => { maxLineWidth: Math.max(...widths), }) }) + + test('prepareRichInline with pre-wrap preserves trailing spaces on the current line', () => { + const prepared = prepareRichInline( + [ + { text: 'Hello ', font: FONT }, + { text: 'World', font: FONT }, + ], + { whiteSpace: 'pre-wrap' }, + ) + // The width of 'Hello ' is sufficient for the first item, + // and the spaces should stay on that line. + const widthOfHelloAndSpaces = measureWidth('Hello ', FONT) + 0.1 + + const lines: string[] = [] + walkRichInlineLineRanges(prepared, widthOfHelloAndSpaces, range => { + const line = materializeRichInlineLineRange(prepared, range) + lines.push(line.fragments.map(f => f.text).join('')) + }) + + expect(lines).toEqual(['Hello ', 'World']) + }) + + test('prepareRichInline default normal mode still collapses boundary whitespace', () => { + const prepared = prepareRichInline([ + { text: ' Hello ', font: FONT }, + { text: ' World ', font: FONT }, + ]) + // Default normal mode: boundary spaces are collapsed into gapBefore. + // The fragments should contain trimmed text with inter-item gap. + const lines: Array<{ text: string, gapBefore: number }[]> = [] + walkRichInlineLineRanges(prepared, 500, range => { + const line = materializeRichInlineLineRange(prepared, range) + lines.push(line.fragments.map(f => ({ text: f.text, gapBefore: f.gapBefore }))) + }) + + expect(lines).toHaveLength(1) + expect(lines[0]![0]!.text).toBe('Hello') + expect(lines[0]![0]!.gapBefore).toBe(0) + expect(lines[0]![1]!.text).toBe('World') + expect(lines[0]![1]!.gapBefore).toBeGreaterThan(0) + }) + + test('prepareRichInline pre-wrap keeps whitespace-only items visible', () => { + const prepared = prepareRichInline( + [ + { text: 'Hello', font: FONT }, + { text: ' ', font: FONT }, + { text: 'World', font: FONT }, + ], + { whiteSpace: 'pre-wrap' }, + ) + // whitespace-only item should not be skipped in pre-wrap mode. + // All three items should contribute fragments. + const fragments: string[] = [] + walkRichInlineLineRanges(prepared, 500, range => { + const line = materializeRichInlineLineRange(prepared, range) + for (const f of line.fragments) fragments.push(f.text) + }) + + expect(fragments).toEqual(['Hello', ' ', 'World']) + }) }) describe('layout invariants', () => { diff --git a/src/rich-inline.ts b/src/rich-inline.ts index 977fc35..7c1f2bc 100644 --- a/src/rich-inline.ts +++ b/src/rich-inline.ts @@ -155,7 +155,11 @@ function endsInsideFirstSegment(segmentIndex: number, graphemeIndex: number): bo return segmentIndex === 0 && graphemeIndex > 0 } -export function prepareRichInline(items: RichInlineItem[]): PreparedRichInline { +export function prepareRichInline( + items: RichInlineItem[], + options?: { whiteSpace?: 'normal' | 'pre-wrap' }, +): PreparedRichInline { + const whiteSpace = options?.whiteSpace ?? 'normal' const preparedItems: PreparedRichInlineItem[] = [] const itemsBySourceItemIndex = Array.from({ length: items.length }) const collapsedSpaceWidthCache = new Map() @@ -164,14 +168,17 @@ export function prepareRichInline(items: RichInlineItem[]): PreparedRichInline { for (let index = 0; index < items.length; index++) { const item = items[index]! const letterSpacing = item.letterSpacing ?? 0 - 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, '') + const isPreWrap = whiteSpace === 'pre-wrap' + const hasLeadingWhitespace = isPreWrap ? false : LEADING_COLLAPSIBLE_BOUNDARY_RE.test(item.text) + const hasTrailingWhitespace = isPreWrap ? false : TRAILING_COLLAPSIBLE_BOUNDARY_RE.test(item.text) + const trimmedText = isPreWrap + ? item.text + : 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) { + if (!isPreWrap && COLLAPSIBLE_BOUNDARY_RE.test(item.text) && pendingGapWidth === 0) { pendingGapWidth = getCollapsedSpaceWidth(item.font, letterSpacing, collapsedSpaceWidthCache) } continue @@ -183,10 +190,13 @@ export function prepareRichInline(items: RichInlineItem[]): PreparedRichInline { : hasLeadingWhitespace ? getCollapsedSpaceWidth(item.font, letterSpacing, collapsedSpaceWidthCache) : 0 + const prepareOptions = isPreWrap + ? (letterSpacing === 0 ? { whiteSpace } : { whiteSpace, letterSpacing }) + : (letterSpacing === 0 ? undefined : { letterSpacing }) const prepared = prepareWithSegments( trimmedText, item.font, - letterSpacing === 0 ? undefined : { letterSpacing }, + prepareOptions, ) const wholeLine = prepareWholeItemLine(prepared) if (wholeLine === null) {