Skip to content

fix(text): bold and italic rich text runs no longer overlap following text - #908

Open
wouterlucas wants to merge 2 commits into
fix/rich-text-line-offsetsfrom
fix/rich-text-styled-advance
Open

wouterlucas wants to merge 2 commits into
fix/rich-text-line-offsetsfrom
fix/rich-text-styled-advance

Conversation

@wouterlucas

Copy link
Copy Markdown
Contributor

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

Fixes the reported defect: with richText: true, The [b]brown[/b] fox renders as brownfox.

Root cause

Styled spans were drawn with a styled face but advanced by unstyled metrics.

Canvas. The draw context was switched to the span font (CanvasTextRenderer.ts:286-289), but the advance came from CanvasFontHandler.measureText, which ignores its fontFamily argument and measures against a shared context still set to the base font (CanvasFontHandler.ts:226-243, primed at CanvasTextRenderer.ts:146). A bold segment therefore advanced by its regular-face width. The deficit — typically 3-6% of segment width — consumes the following space.

SDF. Bold is faked by shifting the shader alpha threshold (SdfShader.ts:178, threshold = 0.5 - v_style * 0.05) and italic by shearing (ITALIC_SHEAR), neither of which touches xadvance (SdfTextRenderer.ts:618). Milder than Canvas — crowding rather than overlap — but wrong.

Changes

  • setMeasureFont / getMeasureFont on CanvasFontHandler so the measure context tracks the draw context. Restored to the base font afterwards, since the context is shared with calculateFontMetrics and the plain text path.
  • SDF bold adds the width the threshold shift actually produces, 2 * shift * distanceRange, centred on the advance by shifting x1 by half.
  • An italic run pays its shear overhang when the run ends, tracked lazily so no lookahead is needed.
  • Kerning suppressed across a style boundary, where the pair adjustment from the regular face does not apply.
  • Shared formulas in a new RichTextMetrics module so measurement and drawing cannot drift apart. The SDF bold constant sits next to a note pointing at the matching shader threshold — changing one without the other reintroduces the bug.

The width correction is not optional

effectiveMaxWidth (TextLayoutEngine.ts:110-121) is the longest base-font-measured line, and in CanvasTextRenderer.ts it both sizes the texture (:176, :192) and is published as the node width (:408).

Fixing the advance alone would push drawn glyphs past canvasW, trading overlap for right-edge clipping, and still under-report width to siblings. So both renderers re-measure each final line with span fonts and recompute effectiveWidth before the canvas is sized. Alignment offsets are recomputed alongside, since non-left alignments reference the widest line.

Known limitation

Line breaking is still base-font based, so a styled line can wrap slightly early or late. That needs style-aware measurement inside the layout engine — PR 3 of this stack.

Testing

tsc --noEmit clean, 325 tests pass, eslint 0 errors. Adds 20 tests: RichTextMetrics unit coverage, plus a CanvasFontHandler suite using a context stub whose measureText depends on the active font, pinning that a styled measurement of The [b]brown[/b] fox exceeds the base measurement.

Styled spans were drawn with a styled face but advanced by unstyled
metrics, so the run overlapped whatever came after it. With
`richText: true`, 'The [b]brown[/b] fox' rendered as 'brownfox'.

Canvas: the draw context was switched to the span's font, but the
advance came from CanvasFontHandler.measureText, which ignores its
fontFamily argument and measures against a shared context still set to
the base font. A bold segment therefore advanced by its regular-face
width. Added setMeasureFont/getMeasureFont so the measure context tracks
the draw context, and restore the base font afterwards since the context
is shared with font metric calculation and the plain text path.

SDF: bold is faked by shifting the shader's alpha threshold and italic by
shearing, neither of which touches xadvance. Bold now adds the width the
threshold shift actually produces (2 * shift * distanceRange, centred on
the advance), and an italic run pays for its shear overhang when it ends.
Kerning is suppressed across a style boundary, where the pair adjustment
from the regular face does not apply.

Both renderers also correct line widths after layout. mapTextLayout is
style-agnostic, so a styled line reported a width narrower than its drawn
extent; that width sizes the Canvas texture and is published as the
node's width, so fixing the advance without it would have traded overlap
for clipping. Alignment offsets are recomputed alongside, since the
non-left alignments reference the widest line.

The shared formulas live in RichTextMetrics so measurement and drawing
cannot drift apart; the SDF bold constant is defined next to a note
pointing at the matching shader threshold.

Line breaking is still base-font based, so a styled line can still wrap
slightly early or late. That needs style-aware measurement inside the
layout engine and is left to a follow-up.
@wouterlucas
wouterlucas force-pushed the fix/rich-text-styled-advance branch from e6bf739 to 97d7aa3 Compare September 19, 2026 13:11
The certified snapshots encoded the bug. text-rich-canvas-9 showed
"bold underline" and "and" drawn on top of each other, reading
"underlineand", which is the defect this branch fixes.

Recaptured in the CI container. Full suite: 209/209 pass. Only
text-rich-* snapshots changed; no other test was affected.
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