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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
26 changes: 18 additions & 8 deletions src/rich-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreparedRichInlineItem | undefined>({ length: items.length })
const collapsedSpaceWidthCache = new Map<string, number>()
Expand All @@ -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
Expand All @@ -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) {
Expand Down