Skip to content

Commit c8fdd67

Browse files
author
wangkai39
committed
fix: never round a text node below its measured width
Yoga rounds a node's left and right edge to the pixel grid independently and derives the width from the difference. For a node with a measure function it aims to never round the size down, "as this could lead to unwanted text truncation". That guarantee leaks. `roundValueToPixelGrid` treats a value as already on the grid when it is within `inexactEquals`' 0.0001 tolerance of a whole pixel, and the two edges can fall on opposite sides of it. For text measured as 23 physical pixels on a 2.75 density screen the left edge lands on 103.9999008 scaled units and is snapped up to 104, while the right edge lands on 126.9998999, misses the tolerance, and is floored to 126 - a 22 pixel wide box for 23 pixels of text. `ReactTextView` then re-breaks the text at the width it is mounted with, so the missing pixel drops the trailing glyph. Recompute the right edge with `forceCeil` only when the width came out below what the node measured. Nodes that already round to at least their measured size, and all nodes without a measure function, are unchanged. Only the width dimension is changed. The height dimension has the same asymmetry, but a height shortfall clips a row of pixels rather than triggering a re-break, and `getLineBottom()` already includes descent and line spacing, so it has no comparable user-visible effect.
1 parent 2fa37b1 commit c8fdd67

2 files changed

Lines changed: 103 additions & 9 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HostInstance} from 'react-native';
14+
15+
import * as Fantom from '@react-native/fantom';
16+
import nullthrows from 'nullthrows';
17+
import * as React from 'react';
18+
import {createRef} from 'react';
19+
import {Text, View} from 'react-native';
20+
21+
// A non-integer screen density, as shipped by many Android devices. One
22+
// physical pixel is 1/2.75 dp.
23+
const DEVICE_PIXEL_RATIO = 2.75;
24+
25+
// 23 physical pixels, as a float32 dp value. Android measures text in whole
26+
// physical pixels and hands Yoga the dp equivalent, so `TEXT_WIDTH * 2.75`
27+
// lands a hair under 23 (22.999999046) rather than exactly on it.
28+
const TEXT_WIDTH = 8.363636016845703;
29+
const TEXT_WIDTH_IN_PIXELS = 23;
30+
31+
function measureTextWidthInPixels(paddingLeft: number): number {
32+
const root = Fantom.createRoot({devicePixelRatio: DEVICE_PIXEL_RATIO});
33+
const textRef = createRef<HostInstance>();
34+
35+
try {
36+
Fantom.runTask(() => {
37+
root.render(
38+
<View collapsable={false} style={{paddingLeft}}>
39+
<Text ref={textRef} style={{width: TEXT_WIDTH}}>
40+
text
41+
</Text>
42+
</View>,
43+
);
44+
});
45+
46+
const {width} = nullthrows(textRef.current).getBoundingClientRect();
47+
return Math.round(width * DEVICE_PIXEL_RATIO);
48+
} finally {
49+
root.destroy();
50+
}
51+
}
52+
53+
// The text node's edges land on opposite sides of the tolerance Yoga uses to
54+
// decide whether a value already sits on the pixel grid: the left edge falls on
55+
// 103.9999008 scaled units and is snapped up to 104, while the right edge falls
56+
// on 126.9998999, misses the tolerance, and is floored to 126. Rounding the two
57+
// edges independently then produced a 22 pixel wide box for 23 pixels of text,
58+
// and the view dropped the trailing glyph when it re-broke the text at that
59+
// width.
60+
test('does not round a text node below the width it measured', () => {
61+
expect(measureTextWidthInPixels(37.818145751953125)).toBe(
62+
TEXT_WIDTH_IN_PIXELS,
63+
);
64+
});
65+
66+
// The counterpart: a text node that already rounds to the width it measured
67+
// must not gain a pixel. Here the left edge falls on 28.875 scaled units, well
68+
// clear of the tolerance, and both edges floor consistently. Forcing the right
69+
// edge to ceil unconditionally would widen this node to 24 pixels.
70+
test('does not widen a text node that already fits', () => {
71+
expect(measureTextWidthInPixels(10.5)).toBe(TEXT_WIDTH_IN_PIXELS);
72+
});

packages/react-native/ReactCommon/yoga/yoga/algorithm/PixelGrid.cpp

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,37 @@ void roundLayoutResultsToPixelGrid(
106106
const bool hasFractionalHeight =
107107
!yoga::inexactEquals(round(scaledNodeHeight), scaledNodeHeight);
108108

109-
node->getLayout().setDimension(
110-
Dimension::Width,
111-
roundValueToPixelGrid(
112-
absoluteNodeRight,
113-
pointScaleFactor,
114-
(textRounding && hasFractionalWidth),
115-
(textRounding && !hasFractionalWidth)) -
116-
roundValueToPixelGrid(
117-
absoluteNodeLeft, pointScaleFactor, false, textRounding));
109+
const float roundedNodeLeft = roundValueToPixelGrid(
110+
absoluteNodeLeft, pointScaleFactor, false, textRounding);
111+
112+
float roundedNodeWidth = roundValueToPixelGrid(
113+
absoluteNodeRight,
114+
pointScaleFactor,
115+
(textRounding && hasFractionalWidth),
116+
(textRounding && !hasFractionalWidth)) -
117+
roundedNodeLeft;
118+
119+
// Rounding the two edges independently can still narrow a node below the
120+
// size it measured, which is what the comment above means to prevent. The
121+
// left and right edge can fall on opposite sides of `inexactEquals`'
122+
// tolerance: for text measured as 23 physical pixels on a 2.75 density
123+
// screen the left edge lands on 103.9999008 scaled units and is snapped up
124+
// to 104, while the right edge lands on 126.9998999, misses the tolerance,
125+
// and is floored to 126 - a 22 pixel wide box for 23 pixels of text.
126+
//
127+
// Recompute the right edge with `forceCeil` in exactly those cases. Nodes
128+
// that already round to at least the size they measured keep the width
129+
// computed above.
130+
const double scaledRoundedNodeWidth =
131+
static_cast<double>(roundedNodeWidth) * pointScaleFactor;
132+
if (textRounding && scaledRoundedNodeWidth < scaledNodeWith &&
133+
!yoga::inexactEquals(scaledRoundedNodeWidth, scaledNodeWith)) {
134+
roundedNodeWidth = roundValueToPixelGrid(
135+
absoluteNodeRight, pointScaleFactor, true, false) -
136+
roundedNodeLeft;
137+
}
138+
139+
node->getLayout().setDimension(Dimension::Width, roundedNodeWidth);
118140

119141
node->getLayout().setDimension(
120142
Dimension::Height,

0 commit comments

Comments
 (0)