Skip to content

[Text] Do not shape the space after fallback text with the fallback font - #22067

Merged
MrJul merged 3 commits into
mainfrom
fix/fallback-run-absorbs-whitespace
Aug 26, 2026
Merged

MrJul merged 3 commits into
mainfrom
fix/fallback-run-absorbs-whitespace

Conversation

@Gillibald

@Gillibald Gillibald commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes the long space after an emoji reported in #14011: a fallback run swallowed the space that follows it and shaped that space with the fallback font.

Top layer of a stack; based on #22066.

What is the current behavior?

TextCharacters.CreateShapeableRun extends a fallback run for as long as the fallback font has glyphs, rather than ending it where the default typeface regains coverage. Whitespace was explicitly exempt from the return-to-primary check so a space would not split a fallback run - but practically every font maps U+0020, so the space after fallback text is pulled into the fallback run and rendered with that font's space glyph. In most emoji fonts that is a full em.

Measured with FormattedText at FontSize=16, isolating the space by differencing:

space advance
plain space 4.45
space immediately after an emoji 16.00 (1em, from the emoji font)

Nothing about it is emoji-specific: any fallback font that maps the space leaks the same way. Characters emoji fonts do not map (U+00A0, U+2060, U+200B) already end the run correctly, which is the same rule seen from the other side.

What is the updated/expected behavior with this PR?

A space next to fallback text measures with the default typeface, the same as anywhere else on the line:

var tf = new Typeface(FontFamily.Default);
double W(string s) => new FormattedText(s, CultureInfo.InvariantCulture,
    FlowDirection.LeftToRight, tf, 16, Brushes.Black).Width;

W("a b") - W("ab");                   // 4.45
W("\U0001F642 b") - W("\U0001F642b"); // 4.45, was 16.00

Or type an emoji followed by a space into a TextBox. This matches browsers, where the primary font wins the space in every position - after an emoji, between two emoji, and between two CJK words (measured in Chrome via canvas measureText for both Arial and Times New Roman).

How was the solution implemented (if it's not obvious)?

  • TryGetShapeableLength applies the return-to-primary check to spacing whitespace (Zs) as well, so a fallback run ends at the first cluster the default typeface can render. Only Zs qualifies: control and format codepoints keep their cluster with the fallback run - many fonts map the default-ignorable bidi controls, and a default typeface whose cmap merely has such a mark must not pull it out of the run (TryGetShapeableLength_Does_Not_Reclaim_A_Bidi_Control_As_Whitespace pins this).
  • The check is gated on a new defaultCanShapeScript argument. A default typeface that cannot shape the run's script was passed as null and so was no return target at all, which is why Arabic and Hebrew lines never reached the check. It is still not a return target for text - handing clusters back would block a shaping-capable fallback that merely shares its cmap - but it does reclaim the spacing whitespace between the words, which carries no shaping.
  • A run of pure whitespace no longer becomes the anti-thrashing bias for the next run. Otherwise the words on either side of a space each resolve their fallback from scratch and can land on different fonts; GetShapeableCharacters_Keeps_The_Previous_Fallback_Across_A_Space pins this with two regional subset fonts.
  • TextLineImpl.TryMergeWithLastBounds compares the two coordinates with MathUtilities.AreClose instead of ==. The edges are reached by summing glyph advances along different paths, so abutting bounds can land an ULP apart (43.20703125 vs 43.20703124999999) and one directional span gets reported as two rectangles.

The first commit adds the failing tests.

Test expectations that encoded the old run structure moved with it. Two are worth a reviewer's eye: glyph clusters are relative to each run's own text (the affected helpers now add the run's start via TextTestHelper.GetStartCharIndex, and one helper also indexed its rect list with a per-run glyph index), and the right-to-left trimming rows concatenate run texts in visual run order, so the same characters regroup - "…שתיים one" becomes "… שתיים one".

Gates: Skia unit 445/445, Base unit 3136/3136, Skia render tests 370/370 with no golden image changed.

Checklist

Breaking changes

No API changes, but the layout of existing text changes where fallback text and whitespace meet:

  • a space adjacent to fallback text measures with the default typeface, so such lines reflow slightly. Widths can go either way - the platform emoji fonts are wider than a typical primary, other fallbacks are narrower.
  • those lines are cut into more runs: four emoji separated by spaces now produce 7 text runs instead of 1. Adjacent runs that share a typeface are still re-merged before shaping, so text without fallbacks is unaffected.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #14011

@Gillibald Gillibald changed the title fix/fallback run absorbs whitespace [Text] Do not shape the space after fallback text with the fallback font Aug 25, 2026
@Gillibald
Gillibald force-pushed the fix/fallback-run-absorbs-whitespace branch from bcd4869 to c075338 Compare August 26, 2026 04:36
@Gillibald
Gillibald force-pushed the fix/fallback-run-absorbs-whitespace branch from c075338 to b0b6073 Compare August 26, 2026 04:51
@Gillibald
Gillibald force-pushed the fix/fallback-run-absorbs-whitespace branch from b0b6073 to a85ca0c Compare August 26, 2026 04:54
@Gillibald
Gillibald marked this pull request as ready for review August 26, 2026 04:55
@MrJul MrJul added area-textprocessing backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch bug labels Aug 26, 2026
@MrJul MrJul self-assigned this Aug 26, 2026
@MrJul
MrJul force-pushed the fix/fallback-run-absorbs-whitespace branch from a85ca0c to a47255e Compare August 26, 2026 09:51
@Gillibald
Gillibald force-pushed the fix/fallback-run-absorbs-whitespace branch from a47255e to 5ea3968 Compare August 26, 2026 10:36
@Gillibald
Gillibald force-pushed the fix/fallback-run-absorbs-whitespace branch 3 times, most recently from fdd332c to 717682c Compare August 26, 2026 13:26
MrJul
MrJul previously approved these changes Aug 26, 2026

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Base automatically changed from fix/rtl-split-inside-cluster to main August 26, 2026 15:17
A fallback run is extended for as long as the fallback font has glyphs
instead of ending where the primary font regains coverage. Practically
every font maps U+0020, so the space that follows fallback text is
pulled into the fallback run and shaped with its space glyph.

- itemization: a Hebrew letter followed by " b" must produce a 1-char
  fallback run, not a 2-char one that swallows the space
- measurement: the space after an emoji must have the same advance as
  the same space elsewhere in the line
The two edges being compared are reached by summing glyph advances along
different paths, so abutting bounds can land an ULP apart and a single
directional span gets reported as two rectangles. Compare them the way
the rest of layout compares coordinates.
Whitespace was exempt from the return-to-primary check so that a space
wouldn't split a fallback run. Practically every font maps U+0020, so
the exemption let a fallback run reach past the text the default
typeface couldn't render and shape the following space with the
fallback's own advance - a full em in most emoji fonts, which is the
long space reported after an emoji.

- the check now applies to spacing whitespace (Zs), so a fallback run
  ends at the first cluster the default typeface can render; control
  and format codepoints stay exempt - many fonts map the
  default-ignorable bidi controls, and a default typeface whose cmap
  merely has such a mark must not pull it out of the fallback run: the
  mark renders nothing either way, and the split cuts the run for no
  reason
- a default typeface that cannot shape the run's script is still not a
  return target for text, but it does reclaim the spacing whitespace
  between the words, which carries no shaping
- a run of pure whitespace no longer becomes the anti-thrashing bias for
  the next run, so the words on either side of a space keep resolving to
  the same fallback font

Test expectations that encoded the old run structure move with it: a
line of emoji separated by spaces is no longer a single run, glyph
clusters are relative to each run's own text (the affected helpers now
add the run's start), and the spaces of a right-to-left line measure
with the primary font, which widens those lines.
@MrJul
MrJul force-pushed the fix/fallback-run-absorbs-whitespace branch from 717682c to ef226ca Compare August 26, 2026 15:17
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0068937-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul
MrJul added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 648edcd Aug 26, 2026
9 checks passed
@MrJul
MrJul deleted the fix/fallback-run-absorbs-whitespace branch August 26, 2026 22:45
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request Sep 2, 2026
…ont (AvaloniaUI#22067)

* Add failing tests for whitespace absorbed into a fallback run

A fallback run is extended for as long as the fallback font has glyphs
instead of ending where the primary font regains coverage. Practically
every font maps U+0020, so the space that follows fallback text is
pulled into the fallback run and shaped with its space glyph.

- itemization: a Hebrew letter followed by " b" must produce a 1-char
  fallback run, not a 2-char one that swallows the space
- measurement: the space after an emoji must have the same advance as
  the same space elsewhere in the line

* Merge abutting text bounds within the usual float tolerance

The two edges being compared are reached by summing glyph advances along
different paths, so abutting bounds can land an ULP apart and a single
directional span gets reported as two rectangles. Compare them the way
the rest of layout compares coordinates.

* End a fallback run where the default typeface regains coverage

Whitespace was exempt from the return-to-primary check so that a space
wouldn't split a fallback run. Practically every font maps U+0020, so
the exemption let a fallback run reach past the text the default
typeface couldn't render and shape the following space with the
fallback's own advance - a full em in most emoji fonts, which is the
long space reported after an emoji.

- the check now applies to spacing whitespace (Zs), so a fallback run
  ends at the first cluster the default typeface can render; control
  and format codepoints stay exempt - many fonts map the
  default-ignorable bidi controls, and a default typeface whose cmap
  merely has such a mark must not pull it out of the fallback run: the
  mark renders nothing either way, and the split cuts the run for no
  reason
- a default typeface that cannot shape the run's script is still not a
  return target for text, but it does reclaim the spacing whitespace
  between the words, which carries no shaping
- a run of pure whitespace no longer becomes the anti-thrashing bias for
  the next run, so the words on either side of a space keep resolving to
  the same fallback font

Test expectations that encoded the old run structure move with it: a
line of emoji separated by spaces is no longer a single run, glyph
clusters are relative to each run's own text (the affected helpers now
add the run's start), and the spaces of a right-to-left line measure
with the primary font, which widens those lines.
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Space character after an emoji character is now very long (Avalonia 11)

4 participants