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
54 changes: 54 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,60 @@ describe('rich-inline invariants', () => {
)
}
})

test('CJK inline-rich items do not overflow maxWidth', () => {
// Regression test for https://github.com/chenglou/pretext/issues/120
// When CJK text is split across multiple rich inline items, the
// line-break engine's "always place at least one unit" guarantee can
// push a line past maxWidth if the rich inline stepper doesn't guard
// against overflow when starting a new item mid-line.
const maxWidth = 100
const prepared = prepareRichInline([
{ text: '你好世界测试', font: FONT },
{ text: '引号里的重点', font: FONT },
{ text: '和括号里的补充', font: FONT },
])

const lines: Array<{ width: number }> = []
walkRichInlineLineRanges(prepared, maxWidth, range => {
const line = materializeRichInlineLineRange(prepared, range)
lines.push({ width: line.width })
})

expect(lines.length).toBeGreaterThan(0)
for (const line of lines) {
expect(line.width).toBeLessThanOrEqual(maxWidth + 0.5) // allow lineFitEpsilon
}

// Also verify stats path stays consistent
const stats = measureRichInlineStats(prepared, maxWidth)
expect(stats.lineCount).toBe(lines.length)
expect(stats.maxLineWidth).toBeLessThanOrEqual(maxWidth + 0.5)
})

test('CJK inline-rich items with inter-item gap do not overflow', () => {
// CJK items separated by whitespace-only items (collapsed to gaps)
const maxWidth = 80
const prepared = prepareRichInline([
{ text: '测试文本 ', font: FONT },
{ text: ' 中文排版', font: FONT },
{ text: ' 溢出修复', font: FONT },
])

const lines: Array<{ width: number }> = []
walkRichInlineLineRanges(prepared, maxWidth, range => {
const line = materializeRichInlineLineRange(prepared, range)
lines.push({ width: line.width })
})

expect(lines.length).toBeGreaterThan(0)
for (const line of lines) {
expect(line.width).toBeLessThanOrEqual(maxWidth + 0.5)
}

const stats = measureRichInlineStats(prepared, maxWidth)
expect(stats.lineCount).toBe(lines.length)
})
})

describe('layout invariants', () => {
Expand Down
23 changes: 23 additions & 0 deletions src/rich-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,20 @@ function stepRichInlineLine(
continue
}

// Guard against overflow: stepPreparedLineGeometry always places at
// least one content unit even when it exceeds maxWidth (to prevent
// infinite loops in standalone layout). When we already have content on
// the current line and the returned fragment would push us past
// safeWidth, break the line before this item so the fragment can start
// fresh on the next line where it will have the full width available.
if (
lineWidth > 0 &&
atItemStart &&
gapBefore + lineWidthForItem + item.extraWidth > remainingWidth
) {
break lineLoop
}

// 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
Expand Down Expand Up @@ -469,6 +483,15 @@ function stepRichInlineLineStats(
continue
}

// Guard against overflow (mirrors the check in stepRichInlineLine)
if (
lineWidth > 0 &&
atItemStart &&
gapBefore + lineWidthForItem + item.extraWidth > remainingWidth
) {
break lineLoop
}

if (
lineWidth > 0 &&
atItemStart &&
Expand Down