Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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.
*/

#pragma once

#include <cmath>

#include <react/renderer/graphics/Size.h>

namespace facebook::react {

/*
* Rounds a raw text measurement up to the device pixel grid.
*
* A small epsilon is added to each dimension before ceil, so a value that sits
* effectively on a physical-pixel boundary gains one extra physical pixel
* instead of being rounded down. This works around precision loss when a
* measurement is converted from double to float and then rounded to the pixel
* grid in Yoga: without it, such a value can round down and clip the final line
* (height) or trailing glyph (width) of text. Dimensions that already have
* sub-pixel headroom are unaffected. See facebook/react-native issue #53450;
* this mirrors the legacy architecture fix in D7074168, which applied the same
* epsilon to both dimensions.
*
* This helper lives in its own header only so the unit test in
* `textlayoutmanager:tests` can exercise it directly. In production it is used
* only by RCTTextLayoutManager.mm, and the `internal_` prefix keeps it out of
* the public C++ API snapshot.
*/
inline Size internal_roundTextMeasurementToPixelGrid(Size size, Float pointScaleFactor)
{
constexpr auto kEpsilon = static_cast<Float>(0.001);
return Size{
.width = std::ceil((size.width + kEpsilon) * pointScaleFactor) / pointScaleFactor,
.height = std::ceil((size.height + kEpsilon) * pointScaleFactor) / pointScaleFactor,
};
}

} // namespace facebook::react
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#import <React/NSTextStorage+FontScaling.h>
#import <React/RCTUtils.h>
#import <react/featureflags/ReactNativeFeatureFlags.h>
#import <react/renderer/textlayoutmanager/TextMeasurementRounding.h>
#import <react/utils/ManagedObjectWrapper.h>
#import <react/utils/SimpleThreadSafeCache.h>

Expand Down Expand Up @@ -581,8 +582,11 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}

size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
facebook::react::Size roundedSize = facebook::react::internal_roundTextMeasurementToPixelGrid(
{.width = static_cast<facebook::react::Float>(size.width),
.height = static_cast<facebook::react::Float>(size.height)},
layoutContext.pointScaleFactor);
size = CGSize{roundedSize.width, roundedSize.height};

NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

#include <gtest/gtest.h>

#include <cmath>

#include <react/renderer/textlayoutmanager/TextMeasureCache.h>
#include <react/renderer/textlayoutmanager/TextMeasurementRounding.h>

using namespace facebook::react;

Expand Down Expand Up @@ -97,3 +100,47 @@ TEST(TextLayoutManagerTest, pointScaleFactorAffectsPreparedTextCacheHash) {
std::hash<PreparedTextCacheKey>{}(lhs),
std::hash<PreparedTextCacheKey>{}(rhs));
}

// Tests for internal_roundTextMeasurementToPixelGrid (the pixel-grid rounding
// used by text measurement). A small epsilon is added before ceil so a
// dimension that lands exactly on a pixel boundary gains one physical pixel of
// slack (avoiding double->float->Yoga precision from clipping the last line or
// trailing glyph), while dimensions with sub-pixel headroom are left untouched.
// pointScaleFactor 3 (an @3x screen) is used so one physical pixel is 1/3 pt.
// These lock in the behavior of D7074168 / PR #54260 in the new architecture,
// with the unit coverage that change lacked.
namespace {
constexpr Float kScale = 3.0;
long heightInPixels(Size raw) {
return std::lround(
internal_roundTextMeasurementToPixelGrid(raw, kScale).height * kScale);
}
long widthInPixels(Size raw) {
return std::lround(
internal_roundTextMeasurementToPixelGrid(raw, kScale).width * kScale);
}
} // namespace

// Height exactly on a pixel boundary gains one extra physical pixel (60 -> 61)
// so the final line is not clipped after layout rounding.
TEST(TextMeasurementRoundingTest, addsSlackWhenHeightOnPixelBoundary) {
EXPECT_EQ(heightInPixels(Size{100.0, 20.0}), 61);
}

// Width exactly on a pixel boundary likewise gains one extra physical pixel so
// the trailing glyph is not clipped (matches the legacy both-dimensions fix).
TEST(TextMeasurementRoundingTest, addsSlackWhenWidthOnPixelBoundary) {
EXPECT_EQ(widthInPixels(Size{20.0, 100.0}), 61);
}

// Height with sub-pixel headroom must NOT be inflated: 20.1pt -> 60.3px ->
// 61px, never 62. The epsilon only nudges boundary values, so text is not made
// taller than needed.
TEST(TextMeasurementRoundingTest, doesNotInflateHeightWithSubpixelHeadroom) {
EXPECT_EQ(heightInPixels(Size{100.0, 20.1}), 61);
}

// Width with sub-pixel headroom must NOT be inflated either: 20.1pt -> 61px.
TEST(TextMeasurementRoundingTest, doesNotInflateWidthWithSubpixelHeadroom) {
EXPECT_EQ(widthInPixels(Size{20.1, 100.0}), 61);
}
29 changes: 29 additions & 0 deletions packages/rn-tester/js/examples/Text/TextSharedExamples.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,27 @@ const styles = StyleSheet.create({
},
});

function LastLineClippingExample(): React.Node {
// Wrapped paragraphs at assorted font sizes are prone to the final line being
// clipped by one physical pixel after layout rounding (issue #53450). The
// border makes any clipping of the last line visible.
return (
<View>
{[11, 12, 13, 14, 15, 16, 17].map(fontSize => (
<View
key={fontSize}
style={{borderWidth: 1, borderColor: '#999999', marginBottom: 6}}>
<Text style={{fontSize}}>
{`(${fontSize}px) This sentence wraps across multiple lines so the ` +
'final line sits near a pixel boundary and must render fully, ' +
'without being cut off.'}
</Text>
</View>
))}
</View>
);
}

export default [
{
title: 'Empty Text',
Expand Down Expand Up @@ -277,4 +298,12 @@ export default [
description: 'Shows the a11y behavior of Text with role="link"',
render: TextWithLinkRoleExample,
},
{
title: 'Wrapped text last-line clipping',
name: 'wrappedLastLineClipping',
description:
'The final line of wrapped text must render fully, not clipped by one ' +
'physical pixel after layout rounding (issue #53450).',
render: LastLineClippingExample,
},
] as ReadonlyArray<RNTesterModuleExample>;
Loading