From c126a0078a179c76c2a7d09af45dcc9636b4a8ae Mon Sep 17 00:00:00 2001 From: Dongkyu Kim Date: Fri, 12 Jun 2026 19:45:20 +0900 Subject: [PATCH] Fix text truncation caused by pixel-grid rounding of near-integer text nodes When rounding layout results to the pixel grid, text nodes whose scaled dimension is close to a whole number (hasFractionalWidth == false) had their trailing edge force-floored. The inexactEquals tolerance (0.0001) can apply asymmetrically to the two edges of the same node: the leading edge falls inside the tolerance and snaps up, while the trailing edge falls just outside it and gets floored. The resulting width is smaller than the measured text width, so text renders with an ellipsis. Real-world case (UILabel on an @3x display, observed via FlexLayout): - absoluteNodeLeft * 3 = 490.999923 -> within 0.0001 of 491 -> snaps UP - absoluteNodeRight * 3 = 654.999894 -> outside tolerance -> floored DOWN - final width 54.333... < measured 54.666656 -> truncated Fix: never force-floor the trailing edge of a text node. Clearly fractional sizes keep the existing force-ceil behavior; near-integer sizes fall through to natural rounding, which snaps 654.999894 up to 655 and preserves the measured width. All existing tests pass unchanged. Adds YGRoundingTextTruncationTest reproducing the bug with the exact values from the real-world case. --- tests/YGRoundingTextTruncationTest.cpp | 187 +++++++++++++++++++++++++ yoga/algorithm/PixelGrid.cpp | 9 +- 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tests/YGRoundingTextTruncationTest.cpp diff --git a/tests/YGRoundingTextTruncationTest.cpp b/tests/YGRoundingTextTruncationTest.cpp new file mode 100644 index 0000000000..bcd038c0af --- /dev/null +++ b/tests/YGRoundingTextTruncationTest.cpp @@ -0,0 +1,187 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include + +// This test reproduces a text truncation bug that occurs when: +// 1. A text node is positioned at an offset where absoluteNodeLeft * scale +// has a fractional part very close to 1.0 (within 0.0001) +// 2. The text node width * scale also has fractional part close to 1.0 +// (hasFractionalWidth = false) +// 3. But absoluteNodeRight * scale has fractional part that is close to +// but NOT within 0.0001 of 1.0 +// +// In the buggy code, when hasFractionalWidth = false and textRounding = true, +// forceFloor was set to true. This caused absoluteNodeRight to be floored, +// while absoluteNodeLeft was correctly rounded up (due to inexactEquals check). +// The mismatch caused the final width to be smaller than the measured width. +// +// Example from real-world bug: +// - absoluteNodeLeft = 163.666641, * 3 = 490.999923 (fmod = 0.999923) +// |1.0 - 0.999923| = 0.000077 < 0.0001 → rounds UP to 491 +// - absoluteNodeRight = 218.333298, * 3 = 654.999894 (fmod = 0.999894) +// |1.0 - 0.999894| = 0.000106 > 0.0001 → floors DOWN to 654 (BUG!) +// - Final width = 654/3 - 491/3 = 163/3 = 54.333... < measured 54.666... (TRUNCATED!) + +// Measure function that returns width causing the truncation bug +// The exact value from the real-world bug case +static YGSize measureTextForTruncationBug( + YGNodeConstRef /*node*/, + float /*width*/, + YGMeasureMode /*widthMode*/, + float /*height*/, + YGMeasureMode /*heightMode*/) { + // From the real bug: nodeWidth = 54.666656 + // 54.666656 * 3 = 163.999968 + // fmod = 0.999968, |1.0 - 0.999968| = 0.000032 < 0.0001 + // This means hasFractionalWidth = false (close to integer) + return YGSize{ + .width = 54.666656f, + .height = 50.0f, + }; +} + +// Test that text nodes positioned at specific offsets don't get truncated +// due to pixel rounding edge cases. +// +// This test FAILS with the buggy code where forceFloor was used when +// hasFractionalWidth = false, and PASSES with the fix that always uses +// forceCeil for text nodes. +TEST(YogaTest, text_node_rounding_with_offset_should_not_truncate) { + YGConfigRef config = YGConfigNew(); + YGConfigSetPointScaleFactor(config, 3.0f); // @3x display (iPhone Plus/Pro) + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 393); // iPhone 15 Pro width + YGNodeStyleSetHeight(root, 100); + YGNodeStyleSetAlignItems(root, YGAlignFlexStart); // Prevent stretching + // Padding creates offset matching real bug case: + // - absoluteNodeLeft = 163.666641 + // - absoluteNodeLeft * 3 = 490.999923 + // - fmod = 0.999923, |1.0 - 0.999923| = 0.000077 < 0.0001 → rounds UP to 491/3 + YGNodeStyleSetPadding(root, YGEdgeLeft, 163.666641f); + + YGNodeRef textNode = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(textNode, measureTextForTruncationBug); + YGNodeInsertChild(root, textNode, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + float measuredWidth = 54.666656f; + float layoutWidth = YGNodeLayoutGetWidth(textNode); + + // With the bug (forceFloor when hasFractionalWidth = false): + // - absoluteNodeRight = 163.666641 + 54.666656 = 218.333297 + // - absoluteNodeRight * 3 = 654.999891, fmod = 0.999891 + // - |1.0 - 0.999891| = 0.000109 > 0.0001 → inexactEquals returns false + // - forceFloor = true → scaledValue floored to 654 + // - roundedRight = 654/3 = 218.0 + // - roundedLeft = 491/3 = 163.666... + // - finalWidth = 218.0 - 163.666... = 54.333... < 54.666656 (TRUNCATED!) + // + // With the fix (forceCeil for text nodes, never forceFloor): + // - forceCeil = true → scaledValue ceiled to 655 + // - roundedRight = 655/3 = 218.333... + // - finalWidth = 218.333... - 163.666... = 54.666... >= 54.666656 ✓ + + EXPECT_GE(layoutWidth, measuredWidth) + << "Text node width should not be truncated. " + << "Measured: " << measuredWidth << ", Layout: " << layoutWidth + << ". This indicates the pixel rounding bug where text nodes get " + << "incorrectly floored instead of ceiled."; + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Additional test with different offset values that also trigger the bug +TEST(YogaTest, text_node_rounding_alternate_offset_should_not_truncate) { + YGConfigRef config = YGConfigNew(); + YGConfigSetPointScaleFactor(config, 3.0f); + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetHeight(root, 100); + YGNodeStyleSetAlignItems(root, YGAlignFlexStart); // Prevent stretching + // Different offset that creates similar boundary condition + // 290.33333 * 3 = 870.99999, very close to 871 + YGNodeStyleSetPadding(root, YGEdgeLeft, 290.33333f); + + YGNodeRef textNode = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(textNode, measureTextForTruncationBug); + YGNodeInsertChild(root, textNode, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + float measuredWidth = 54.66665f; + float layoutWidth = YGNodeLayoutGetWidth(textNode); + + EXPECT_GE(layoutWidth, measuredWidth) + << "Text node width should not be truncated. " + << "Measured: " << measuredWidth << ", Layout: " << layoutWidth; + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Test that the fix doesn't break normal text rounding behavior +// (text with clearly fractional width should still ceil) +TEST(YogaTest, text_node_with_fractional_width_still_ceils) { + YGConfigRef config = YGConfigNew(); + YGConfigSetPointScaleFactor(config, 3.0f); + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetAlignItems(root, YGAlignFlexStart); + + YGNodeRef textNode = YGNodeNewWithConfig(config); + // 54.5 * 3 = 163.5, fmod = 0.5 + // This is clearly fractional (hasFractionalWidth = true) + // Should still ceil to 164/3 = 54.666... + YGNodeSetMeasureFunc(textNode, [](YGNodeConstRef, float, YGMeasureMode, + float, YGMeasureMode) -> YGSize { + return YGSize{.width = 54.5f, .height = 50.0f}; + }); + YGNodeInsertChild(root, textNode, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + float layoutWidth = YGNodeLayoutGetWidth(textNode); + + // Should be ceiled: 54.5 * 3 = 163.5 → ceil → 164 / 3 = 54.666... + EXPECT_GE(layoutWidth, 54.5f); + EXPECT_FLOAT_EQ(54.666666f, layoutWidth); + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} + +// Test text at zero offset (simple case, should always work) +TEST(YogaTest, text_node_at_zero_offset_should_not_truncate) { + YGConfigRef config = YGConfigNew(); + YGConfigSetPointScaleFactor(config, 3.0f); + + YGNodeRef root = YGNodeNewWithConfig(config); + YGNodeStyleSetWidth(root, 400); + YGNodeStyleSetAlignItems(root, YGAlignFlexStart); + + YGNodeRef textNode = YGNodeNewWithConfig(config); + YGNodeSetMeasureFunc(textNode, measureTextForTruncationBug); + YGNodeInsertChild(root, textNode, 0); + + YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR); + + float measuredWidth = 54.66665f; + float layoutWidth = YGNodeLayoutGetWidth(textNode); + + EXPECT_GE(layoutWidth, measuredWidth) + << "Text node width should not be truncated even at zero offset."; + + YGNodeFreeRecursive(root); + YGConfigFree(config); +} diff --git a/yoga/algorithm/PixelGrid.cpp b/yoga/algorithm/PixelGrid.cpp index 61de2be2e8..09fd5ff79e 100644 --- a/yoga/algorithm/PixelGrid.cpp +++ b/yoga/algorithm/PixelGrid.cpp @@ -106,13 +106,18 @@ void roundLayoutResultsToPixelGrid( const bool hasFractionalHeight = !yoga::inexactEquals(round(scaledNodeHeight), scaledNodeHeight); + // Never force-floor the trailing edge of a text node: when the scaled + // dimension is close to a whole number but the trailing edge falls just + // outside the inexactEquals tolerance while the leading edge falls inside + // it, flooring shrinks the node below its measured size and truncates + // text. Natural rounding keeps near-integer values intact. node->getLayout().setDimension( Dimension::Width, roundValueToPixelGrid( absoluteNodeRight, pointScaleFactor, (textRounding && hasFractionalWidth), - (textRounding && !hasFractionalWidth)) - + false) - roundValueToPixelGrid( absoluteNodeLeft, pointScaleFactor, false, textRounding)); @@ -122,7 +127,7 @@ void roundLayoutResultsToPixelGrid( absoluteNodeBottom, pointScaleFactor, (textRounding && hasFractionalHeight), - (textRounding && !hasFractionalHeight)) - + false) - roundValueToPixelGrid( absoluteNodeTop, pointScaleFactor, false, textRounding)); }