Skip to content

feat(text): make text layout style-aware so rich text wraps correctly - #909

Draft
wouterlucas wants to merge 2 commits into
fix/rich-text-styled-advancefrom
feat/style-aware-text-layout
Draft

wouterlucas wants to merge 2 commits into
fix/rich-text-styled-advancefrom
feat/style-aware-text-layout

Conversation

@wouterlucas

Copy link
Copy Markdown
Contributor

Part 3 of 4. Base is #908review that first; this diff is isolated against it.

Problem

MeasureTextFn had no style parameter, so line breaking measured every substring with the base font. A bold run that fits under regular metrics but overflows in bold wrapped in the wrong place, and a line reported a width narrower than its drawn extent.

PR 2 corrected the drawn extent after the fact. This corrects the measurement the wrapping decision is based on.

Approach

MeasureTextFn gains an optional start: the absolute index of the substring within the text passed to mapTextLayout. The layout engine threads it through wrapLine, the three wrap strategies, and the per-character scans in truncateLineEnd, truncateWord and splitWord.

The parameter is optional and a negative value means "not locatable in the source", which is what separators and the overflow suffix pass. The plain text path and existing callers are unaffected.

The layout engine does not learn about rich text. Each renderer supplies a measure function closed over its own parsed spans:

  • Canvas switches the shared measure font per style run.
  • SDF applies the synthetic bold advance and italic overhang.

Splitting at span boundaries preserves kerning within a run and drops it across one, where it does not apply.

Note on the retained width correction

PR 2 introduced correctStyledLineWidths in both renderers. It is kept rather than deleted, because a line width is still summed from word widths plus base-font separator widths, and a line-final italic overhang sits outside any word — so the accumulated width is not exactly the drawn extent.

It now just calls the same style-aware measure function instead of duplicating the span walk, which removes roughly 100 lines of near-duplicate logic across the two renderers.

Reviewer caution

Measurement is no longer a pure function of the substring — it depends on the offset too. Any future memoisation must key on (start, len), not the string alone. Called out in the commit message.

Testing

tsc --noEmit clean, 331 tests pass, eslint 0 errors. Adds 6 tests asserting that every located substring genuinely lives at the offset reported, that the overflow suffix is measured unstyled, that a styled run forces an earlier break, that a styled run off the line does not perturb it, that styled lines report greater width, and that mid-word break-all splits respect styled characters.

Line breaking measured every substring with the base font, so a bold run
that fits under regular metrics but overflows in bold wrapped in the
wrong place, and a line's reported width understated its drawn extent.
The previous change corrected the drawn extent after the fact; this
corrects the measurement that the wrapping decision itself is based on.

MeasureTextFn gains an optional `start`, the absolute index of the
substring within the text passed to mapTextLayout. The layout engine now
threads that offset through wrapLine, the three wrap strategies, and the
per-character scans in truncateLineEnd, truncateWord and splitWord. The
parameter is optional and a negative value means "not locatable in the
source", which is what separators and the overflow suffix pass, so the
plain text path and existing callers are unaffected.

Each renderer supplies a measure function closed over its parsed spans
rather than the layout engine learning about rich text: Canvas switches
the shared measure font per style run, SDF applies the synthetic bold
advance and italic overhang. Splitting at span boundaries preserves
kerning within a run and drops it across one, where it does not apply.

The line width correction is kept, since a line's width is still summed
from word widths plus base-font separators and a line-final italic
overhang sits outside any word, but it now just calls the same style-
aware measure function instead of duplicating the span walk.

Note that measurement is no longer a pure function of the substring, so
any future memoisation must key on the offset as well as the text.
@wouterlucas
wouterlucas force-pushed the feat/style-aware-text-layout branch from 307035d to 44d9803 Compare September 18, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant