Rich-inline has a second line walker that keeps diverging from plain text layout
This issue collects why prepareRichInline() keeps giving different lines from plain text layout for the same text, and proposes a fix at the root. Nothing is built yet.
Checked against main at ac78801 on 2026-09-16. main:path:N is a line at ac78801, and :N alone is a line in main:ENGINE_FOLLOWUPS.md.
Terms
- Item: one
{ text, font, break?, extraWidth? }entry passed toprepareRichInline(), usually one styled span. extraWidth: an item's padding and borders, measured by the caller and passed in.- Plain walker: the line walker in
src/line-break.tsbehindlayout(),walkLineRanges()andlayoutNextLine(). ENGINE_FOLLOWUPS.md calls it the flat walker. - Joined text: the items' texts run together as one text node would hold them, cut at collapsible spaces. Browsers find break opportunities in that text, not in each item alone.
- Engine profile: the browser behaviour Pretext picks once per process from the user agent: Chromium, WebKit or Gecko.
- Gap: the collapsed space between two items.
- Unfit hyphen: a soft hyphen where a line could end, but whose hyphen doesn't fit.
- Fake canvas: the deterministic
measureText()stand-in insrc/layout.test.ts.
How it works today
prepareRichInline()prepares each item on its own, asprepareWithSegments(item.text)(main:src/rich-inline.ts:565-569).- Where items meet without a collapsible space, it analyzes the text again to find breaks at item edges:
- in the Chromium and Gecko profiles, and for engines Pretext doesn't recognize, over the joined text (
getJoinedBreakOffsets(), main:src/rich-inline.ts:210); - in the WebKit profile, over the previous item's last two characters followed by the next item's text (RESEARCH.md, "Rich Inline Boundaries").
- in the Chromium and Gecko profiles, and for engines Pretext doesn't recognize, over the joined text (
- Those breaks are mapped back to cursors inside each item, down to a grapheme (main:src/rich-inline.ts:183-202 and 264-310).
stepRichInlineLine()(main:src/rich-inline.ts:669-882) is a second line walker. It calls the plain walker'sstepPreparedLineGeometry()inside each item, but decides on its own whether an item, its gap and itsextraWidthfit at an item edge, whether a run that continues into the next item moves to the next line, where to end when an item's own segments hide a joined break, and whether to break before an item whose line overflows only by a hyphen. For that last case it borrows the plain walker'scanReturnFromUnfitHyphen().src/rich-inline.tsis 1,006 lines, next to 1,173 insrc/line-break.ts.
In ad2bc4e (#136), rich-inline's two steppers, one for lines and one for stats, became one: #132 had to add the same overflow guard to both, and a fix could easily land in only one. The same risk now sits one level up, between rich-inline and the plain walker.
What it costs
Recorded in ENGINE_FOLLOWUPS.md, each a case where rich-inline gives different lines or widths from the plain walker for the same text:
- a collapsed space before an item holding only a soft hyphen is lost (:97);
- the break after a soft hyphen that starts an item after other content on the line is missed, so line ends move back as the width grows (:98);
- a soft hyphen that ends an item where a line breaks gets no hyphen (:99);
- in the Chromium profile, returning from an unfit hyphen stops at the break before the item, where the plain walker returns to an earlier soft hyphen (:100);
- the line width and an item's available width clamp to at least 1px, where the plain walkers clamp to 0 (:95).
#323 was one more: rich-inline broke before an item when only its hyphen overflowed, so a paragraph gained a line as the width grew. #327 fixed it by making the second walker decide that case the way plain text does.
Related problems:
extraWidthis charged on every piece of an item split across lines, where browsers pad only the start of the first piece and the end of the last (:96).- Items are measured apart, so Chrome's and Firefox's shaping and kerning across items is missed: by about 1px, and by 9.77px where an item edge splits a Myanmar cluster (:94).
- In Chrome, the break decision after a word-initial hyphen depends on text in the previous item, as in items
fooand U+2010bar baz, and rich-inline doesn't follow that (:72). Under the fake canvas those items also lay out differently from plainfoo‐bar bazat 28.8-38.3px in every profile but Gecko's, so part of this may be the second walker too.
Where items meet without a collapsible space, rich preparation also analyzes that stretch of text twice: in each item alone, then joined across the boundary. That second pass is small: about 1% of prepareRichInline() per Markdown chat message (0.23-0.26 µs of 24-27 µs in bun), and about half only in a worst case like 1,120 Chinese characters split into 160 items with no spaces. Each item's own analyzeText() is about two thirds.
Proposal
Make plain text the one-item case of rich text:
- One walker over items. Break opportunities at item edges still come from the joined text (per item in the WebKit profile), while each item keeps its own handle: fragment cursors index
prepareWithSegments(item.text)(main:src/rich-inline.ts:564-569), and the wrapping suite checks that (main:tests/wrapping/contracts.ts:346-364). Under the fake canvas, an item's own segments differ from the joined text in 457 of 3,000 seeded random flows in the Chromium profile, so taking handles from the joined analysis would move public cursors. - A fast path when there's only one item, so
prepare(),layout()andmeasureRichInlineStats(), which the Markdown chat calls, don't get slower. - Plain and rich layout then share one walker, so a fix to one reaches the other. The 1px clamp (:95) and the soft-hyphen cases (:98-100) go away by construction. The lost space (:97) comes from how
prepareRichInline()records collapsed spaces between items (main:src/rich-inline.ts:622-625), so it needs its own fix there.extraWidth(:96) still needs the browsers' rule, padding only the start of the first piece and the end of the last, but only in that one walker. - Shaping across items (:94) still needs its own fix, such as the prepare-time boundary correction that entry suggests.
First step: measure the drift
Before restructuring, run every text in the wrapping suite with white-space: normal through both paths under the fake canvas at many widths, split into same-font items: at every offset, or at seeded random offsets that include positions next to spaces, soft hyphens and hyphens. Lay out the items with prepareRichInline() and walkRichInlineLineRanges(), and the joined text with prepareWithSegments(text, font) and walkLineRanges(). Count where line end offsets in the joined text, or line widths, differ. Compare offsets rather than fragment text, since a collapsed space at an item edge becomes gapBefore instead of text. Report the WebKit profile separately, since its boundary breaks come from each item's own text on purpose.
One item isn't enough: it never runs the joined analysis or the item-edge checks, and under the fake canvas it agrees with plain text at every width. Two-item splits of the inter\u00ADna\u00ADtion\u00ADal hello wor\u00ADld disagree at 18 of its 32 split points in the Gecko, WebKit and unrecognized profiles, and at 8 in the Chromium profile. One of those isn't recorded anywhere: items the i and nter\u00ADna\u00ADtion\u00ADal hello wor\u00ADld at 82.5px start the / interna- / tional , where plain text starts the inter- / national .
No browser is needed. Rich-inline has no pre-wrap (#173), so that mode is out.
Open
- Do this before or after #321's stage 2 (break opportunities from engine data, Chrome and Safari first)? That stage changes where both walkers get their break opportunities, and #321 section 4.1 lists rich-inline's
getJoinedBreakOffsets()and its WebKit item-text branch among the callers to keep aligned. #321's stage 7 already plans rich-inline on the new ports.
Source: chenglou/pretext