Use epsilon-aware greater than in TextLine and TextFormatter #20650
+4
−4
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
What does the pull request do?
Prevents tiny floating-point discrepancies from falsely triggering wrapping or overflow detection.
What is the current behavior?
This extremely hard to reproduce in a minimal sample, but in my project I see the following:
When using
TextWrapping.Wrapon Android/iOS in a TextBlock of specific size and content, parts of text blocks with certain width get cut off and are not visible (even when wrapping isn't actually required).For instance,
into eternal lifeis rendered asinto eternal.into eternal iifeis rendered that way too, supposedly becauseiandlhave the same width. However,into eternal lifais rendered in full (eandahave different widths). If I increase or decrease the font size (or slightly change margins somewhere in the visual hierarchy), the issue disappears.I can reliably reproduce this on Android ARM64 device, iOS device and simulator, but not in Android x86 emulator (it's possible it would reproduce with a different string).
What is the updated/expected behavior with this PR?
Text blocks are wrapped correctly and shown in their entirety when wrapping is not required.
How was the solution implemented (if it's not obvious)?
Use
MathUtilities.GreaterThan, which returnstrueonly if the difference is greater than epsilon, instead of>.Even though this fixes the issue in my project, I am still a bit apprehensive. How have other people not run into this problem? It's somewhat of an edge case, but still.
Opus' analysis 1:
Due to IEEE 754 floating-point associativity:
(a+b) + (c+d) ≠ ((a+b) + c) + d. The flat sum can be slightly larger than the hierarchical sum. When the paragraph constraint was derived from the hierarchical sum (from a previous layout pass duringMeasureOverride(Size)), the flat sum inMeasureLength(IReadOnlyList<TextRun>, double)can barely exceed it, falsely triggering wrapping.This explains the symptoms:
• Character-width dependence: different advance values shift the floating-point accumulation pattern, sometimes crossing the threshold and sometimes not
• Font-size dependence:
textScale = fontSize / unitsPerEmchanges the mantissa bits of all advances, changing which accumulations trigger the error• ARM vs x86: different native HarfBuzz/Skia builds or different system fonts for fallback produce different advance values, shifting the boundary
• "Completely cut off": the text wraps during
ArrangeOverride(Size)(creating 2 lines) but was measured for 1 line duringMeasureOverride(Size), so the second line falls outside the TextBlock's allocated heightOpus analysis 2:
The
paragraphWidthconstraint reachingMeasureLength(IReadOnlyList<TextRun>, double)during the arrange pass may not be the exact same value as the text's own computed width. The discrepancy doesn't require multiple runs — it comes from how the width constraint flows through the layout system.During measure, the
TextLayoutuses a largeMaxWidth(from the parent). The text fits on one line and reports WidthIncludingTrailingWhitespace = W.During arrange, the
TextLayoutis recreated from scratch (line 769 inSelectableTextBlock.cs:_textLayout = null). TheMaxWidththis time isfinalSize.Width - padding, which came through the parent's arrange logic — involvingDeflate(double), Inflate(Thickness), RoundLayoutSizeUp(Size, double, double), and DPI-scaled arithmetic. Each of these operations introduces potential floating-point rounding. By the time the value arrives atMeasureLength(IReadOnlyList<TextRun>, double)asparagraphWidth, it may differ from the freshly re-computed text width by a fraction of a ULP.The raw > had zero tolerance for this, so a discrepancy as small as 2.2e-16 relative to the values involved could trigger wrapping.
GreaterThan(double, double)treats values within machine-epsilon as equal, which is the correct behavior for a width comparison that went through different arithmetic paths.Checklist
Breaking changes
No