Fix text truncation caused by pixel-grid rounding of near-integer text nodes#1975
Fix text truncation caused by pixel-grid rounding of near-integer text nodes#1975stleamist wants to merge 1 commit into
Conversation
…t 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.
|
I've put together a minimal iOS reproduction of this bug that uses nothing but the yoga C API and a single The label is measured by a yoga measure function and laid out on a @3x pixel grid (iPhone 15 Pro simulator) at an offset where the node's left edge scales to just inside the 0.0001
The core of the trigger (full code in the repo): // Measure the real label, then nudge the width up to the next value whose
// scaled size sits just inside the tolerance of a whole pixel — the same
// shape of value that real text measurement produces naturally. This makes
// hasFractionalWidth == false, enabling the force-floor path.
CGSize fitted = [view.label sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)];
double wholePixels = ceil(fitted.width * 3.0);
double measuredWidth = (wholePixels - 0.000032) / 3.0;
// Left edge: scales to 490.999923, *within* 0.0001 of 491 → snaps UP.
// Right edge (left + width): ends up ~0.000109 away from a whole pixel,
// *outside* the tolerance → force-floored DOWN. The node loses one pixel.
YGNodeStyleSetPadding(root, YGEdgeLeft, (float)((491.0 - 0.000077) / 3.0));The package pins yoga to current |


Summary
Fixes a text truncation bug in
roundLayoutResultsToPixelGrid(): when a text node's scaled dimension is close to a whole number (hasFractionalWidth == false), the trailing edge was force-floored. Because theinexactEqualstolerance (0.0001) can apply asymmetrically to the two edges of the same node, the leading edge may snap up while the trailing edge gets floored, producing a final width smaller than the measured text width — so the text renders with an ellipsis.Real-world case
Observed with a right-aligned
UILabellaid out via FlexLayout on an @3x display (393pt-wide device). The measured text width was54.666656, positioned atabsoluteNodeLeft = 163.666641:nodeWidth = 54.666656hasFractionalWidth = falseforceFloorenabledabsoluteNodeLeft = 163.666641absoluteNodeRight = 218.333297Final width = (654 − 491) / 3 = 54.333… < measured 54.666656 → truncated.
Both edges sit essentially on the same pixel boundary, but a 0.00003 difference makes them round in opposite directions, shrinking the node by a full pixel.
Fix
Never force-floor the trailing edge of a text node:
hasFractionalWidth == true(clearly fractional): keep the existing force-ceil behavior.hasFractionalWidth == false(near-integer): fall through to natural rounding, which snaps 654.999891 up to 655 and preserves the measured width.This also matches the intent of the existing comment in the function: "If a node has a custom measure function we never want to round down its size as this could lead to unwanted text truncation."
Tests
tests/YGRoundingTextTruncationTest.cppwith four tests, including one reproducing the exact values from the real-world case above (fails before the fix, passes after).Related issues