Skip to content

Commit 2367fbc

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Measure text and inputs in Fantom with Skia (#59482)
Summary: Pull Request resolved: #59482 Use Skia for intrinsic text layout so Fantom tests exercise real wrapping and font scaling. Keep measurement enabled by default and share layout and a consistent CPU configuration with screenshot painting to avoid host-dependent results. Pinned font development dependencies make internal and open-source rendering self-contained and reproducible. Changelog: [Internal] Add intrinsic text and input measurement to Fantom with Skia. Differential Revision: D126888503
1 parent bd08daf commit 2367fbc

31 files changed

Lines changed: 1630 additions & 18 deletions

‎.flowconfig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ module.name_mapper='^react-native/src/fb_internal/\(.*\)$' -> '<PROJECT_ROOT>/pa
6868
# `src/fb_internal/*` stay importable.
6969
module.name_mapper='^react-native/\(Libraries\|src/private\|src/types\)/.*$' -> 'react-native-deep-import-is-not-part-of-the-public-api'
7070
module.name_mapper='^@react-native/dev-middleware$' -> '<PROJECT_ROOT>/packages/dev-middleware'
71-
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|heif\)$' -> '<PROJECT_ROOT>/packages/react-native/Libraries/Image/RelativeImageStub'
71+
module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\|xml\|ktx\|heic\|ttf\|heif\)$' -> '<PROJECT_ROOT>/packages/react-native/Libraries/Image/RelativeImageStub'
7272

7373
module.system.haste.module_ref_prefix=m#
7474

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@babel/preset-env": "^7.25.3",
6565
"@babel/preset-flow": "^7.24.7",
6666
"@electron/packager": "^20.0.0",
67+
"@expo-google-fonts/noto-color-emoji": "0.4.7",
6768
"@expo/spawn-async": "^1.7.2",
6869
"@jest/create-cache-key-function": "^29.7.0",
6970
"@jest/test-sequencer": "^29.7.0",
@@ -82,6 +83,7 @@
8283
"connect": "^3.6.5",
8384
"debug": "^4.4.0",
8485
"deep-equal": "1.1.1",
86+
"dejavu-fonts-ttf": "2.37.3",
8587
"eslint": "^8.57.0",
8688
"eslint-config-prettier": "^8.5.0",
8789
"eslint-plugin-babel": "^5.3.1",

‎packages/react-native/Libraries/Image/ImageTypes.flow.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {HostInstance} from '../..';
1212
import type {RootTag} from '../Types/RootTagTypes';
1313
import type {ResolvedAssetSource} from './AssetSourceResolver';
1414
import type {ImageProps as ImagePropsType} from './ImageProps';
15-
import type {ImageSource} from './ImageSource';
15+
import typeof resolveAssetSource from './resolveAssetSource';
1616

1717
import * as React from 'react';
1818

@@ -59,7 +59,7 @@ type ImageComponentStaticsIOS = Readonly<{
5959
/**
6060
* @see https://reactnative.dev/docs/image#resolveassetsource
6161
*/
62-
resolveAssetSource(source: ImageSource): ?ImageResolvedAssetSource,
62+
resolveAssetSource: resolveAssetSource,
6363
}>;
6464

6565
type ImageComponentStaticsAndroid = Readonly<{

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<eda44383e13efcfb4f814115c4b44547>>
7+
* @generated SignedSource<<fcf142a69b68104edc8bfe851de32d6f>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -75,6 +75,7 @@ declare const $$index: {
7575
}
7676
declare const $$NativeDeviceInfo: typeof NativeModule_default
7777
declare const $$ReactFabric: typeof ReactFabric_default
78+
declare const $$resolveAssetSource: typeof resolveAssetSource_default
7879
declare const $$ScrollViewContext: typeof ScrollViewContext_default
7980
declare const absoluteFill: AbsoluteFillStyle
8081
declare const AccessibilityInfo: typeof AccessibilityInfo_default
@@ -917,6 +918,7 @@ declare type $$flattenStyle = typeof $$flattenStyle
917918
declare type $$index = typeof $$index
918919
declare type $$NativeDeviceInfo = typeof $$NativeDeviceInfo
919920
declare type $$ReactFabric = typeof $$ReactFabric
921+
declare type $$resolveAssetSource = typeof $$resolveAssetSource
920922
declare type $$ScrollViewContext = typeof $$ScrollViewContext
921923
declare type absoluteFill = typeof absoluteFill
922924
declare type AbsoluteFillStyle = {
@@ -2437,6 +2439,7 @@ declare type ImageComponentStaticsAndroid = Readonly<
24372439
}
24382440
>
24392441
declare type ImageComponentStaticsIOS = {
2442+
readonly resolveAssetSource: resolveAssetSource
24402443
getSize(uri: string): Promise<ImageSize>
24412444
getSize(
24422445
uri: string,
@@ -2466,7 +2469,6 @@ declare type ImageComponentStaticsIOS = {
24662469
queryCache(urls: Array<string>): Promise<{
24672470
[url: string]: "disk" | "disk/memory" | "memory"
24682471
}>
2469-
resolveAssetSource(source: ImageSource): ImageResolvedAssetSource | undefined
24702472
}
24712473
declare type ImageErrorEvent = NativeSyntheticEvent<
24722474
Readonly<ImageErrorEventData>
@@ -4116,6 +4118,10 @@ declare type RequiredVirtualizedSectionListProps<
41164118
sections: ReadonlyArray<SectionListData<ItemT, SectionT>>
41174119
}
41184120
declare type requireNativeComponent = typeof requireNativeComponent
4121+
declare type resolveAssetSource = typeof $$resolveAssetSource
4122+
declare function resolveAssetSource_default(
4123+
source: ImageSource | null | undefined,
4124+
): null | ResolvedAssetSource | undefined
41194125
declare type ResolvedAssetSource = {
41204126
readonly height: number | undefined
41214127
readonly scale: number
@@ -5791,7 +5797,7 @@ export {
57915797
AlertOptions, // 8a116d2a
57925798
AlertType, // 5ab91217
57935799
AndroidKeyboardEvent, // e03becc8
5794-
Animated, // 33fe7464
5800+
Animated, // adba9272
57955801
AppConfig, // 35c0ca70
57965802
AppRegistry, // edec2a30
57975803
AppState, // 12012be5
@@ -5856,10 +5862,10 @@ export {
58565862
IEventEmitter, // fbef6131
58575863
IOSKeyboardEvent, // e67bfe3a
58585864
IgnorePattern, // ec6f6ece
5859-
Image, // a24a5777
5860-
ImageBackground, // 1fbbacef
5861-
ImageBackgroundInstance, // 9c900a60
5862-
ImageBackgroundProps, // b078f0ac
5865+
Image, // 404e5c9b
5866+
ImageBackground, // d2afc8de
5867+
ImageBackgroundInstance, // 2e9ed91b
5868+
ImageBackgroundProps, // d49fc98c
58635869
ImageErrorEvent, // b6227714
58645870
ImageInstance, // df709524
58655871
ImageLoadEvent, // 31709c38

‎packages/react-native/src/private/testing/fantom/specs/NativeFantom.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ interface Spec extends TurboModule {
116116
surfaceId: RootTag,
117117
config: RenderFormatOptions,
118118
) => string;
119+
registerAsset(uri: string, filePath: string): void;
120+
registerFont(filePath: string): void;
119121
reportTestSuiteResultsJSON: (results: string) => void;
120122
createShadowNodeReferenceCounter(
121123
shadowNode: unknown /* ShadowNode */,

‎private/react-native-fantom/__docs__/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ values. If another root is still alive with different display metrics, Fantom
9999
applies the new values and emits a warning. Call `root.destroy()` when a root is
100100
no longer needed.
101101

102+
Each root also uses its `fontScale` for native text and text-input measurement,
103+
including line heights and wrapping. `allowFontScaling={false}` opts a component
104+
out, and `maxFontSizeMultiplier` caps its scale. Existing roots retain their own
105+
native font scale when a new root changes the global display metrics.
106+
102107
For a full API reference, please see the [inline documentation](../src/index.js)
103108
defined for the methods in the `@react-native/fantom` [module](../src/index.js).
104109

@@ -714,3 +719,12 @@ The runner then follows these steps:
714719
[React Native Feature Flags](../../../packages/react-native/src/private/featureflags/__docs__/README.md)
715720
system, via the `@fantom_flags` pragmas defined in the docblock for test
716721
files.
722+
723+
## Text measurement
724+
725+
Fantom always measures intrinsic `Text` and `TextInput` sizes so layout tests
726+
exercise wrapping and font scaling without extra setup. Measurement and painting
727+
share Skia and bundled fonts to give tests the same layout on every host.
728+
729+
See [Rendering](rendering.md) for font selection, font scaling, native build
730+
dependencies and cross-platform raster configuration.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Rendering
2+
3+
## Text measurement
4+
5+
Fantom always measures intrinsic `Text` and `TextInput` sizes with Skia's CPU
6+
font metrics. Layout and screenshot painting share the same text layout code,
7+
including fragment styles, constrained-width wrapping, inline attachments, input
8+
values and placeholders. `maximumNumberOfLines` limits the measured box; clipped
9+
attachments retain their entries in Fabric's attachment mapping.
10+
11+
A root's `fontScale` defaults to `1` and is passed to native layout. Text and
12+
inputs respect `allowFontScaling` and `maxFontSizeMultiplier`; line heights
13+
scale with the font size. `devicePixelRatio` determines raster resolution,
14+
rather than the logical text size.
15+
16+
### Fonts and assets
17+
18+
Fantom registers DejaVu Sans regular, bold, italic and bold italic faces from
19+
[`dejavu-fonts-ttf`](https://www.npmjs.com/package/dejavu-fonts-ttf), plus
20+
[`@expo-google-fonts/noto-color-emoji`](https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji).
21+
Both are pinned development dependencies installed by the repository's normal
22+
Yarn setup. Their font licenses are compatible with React Native distribution;
23+
the packages retain the Bitstream Vera/DejaVu and SIL Open Font License notices.
24+
Registration runs once during Fantom's default environment setup, including when
25+
a test has already initialized React Native itself. No host font installation or
26+
Expo runtime is required.
27+
28+
Metro preserves the source files of bundled assets in Fantom's asset metadata.
29+
The shared resolver selects the asset variant for the root's pixel ratio and
30+
registers its native file path. Ordinary `require()` sources therefore work
31+
without custom path mappings. Keep a fixture alongside the test that uses it.
32+
33+
Default color emoji selection follows Unicode 16.0 `Emoji_Presentation`.
34+
Explicit text and emoji variation selectors override that default. Selectors and
35+
keycap sequences stay with their bases during fallback and wrapping; HarfBuzz
36+
shapes color emoji sequences for both measurement and painting. General script
37+
shaping, text decorations and ellipsis are not implemented.
38+
39+
### Public native build
40+
41+
`yarn workspace @react-native/fantom build` uses Gradle to prepare pinned Skia,
42+
FreeType, libpng, zlib, HarfBuzz and BoringSSL sources. CMake builds HarfBuzz
43+
and BoringSSL as static libraries, and builds Skia with GN and the Android SDK's
44+
Ninja. GN bootstrap uses Python 3. The usual JDK, Android SDK, C/C++ compiler
45+
and Python build tools are required; graphics, crypto and font libraries come
46+
from these prepared sources or bundled assets.
47+
48+
The native Gradle task tracks C++ sources so edits rebuild the tester.
49+
Dependency versions and download locations are defined in
50+
[`build.gradle.kts`](../build.gradle.kts). Native integration is configured in
51+
[`tester/CMakeLists.txt`](../tester/CMakeLists.txt) and its third-party
52+
subdirectories.
53+
54+
### Consistent raster configuration
55+
56+
Tests share snapshot baselines across Linux and macOS. Different font metrics
57+
can change line wrapping and the placement of other views; different raster
58+
rounding can change pixel hashes even when the images look alike. Keeping both
59+
consistent makes a failing snapshot useful evidence of a rendering change,
60+
independent of the machine running the test.
61+
62+
Both native builds use CPU Skia, FreeType and the same bundled fonts. Host font
63+
managers and platform shaping backends are disabled. Text gamma correction is
64+
configured identically in both builds.
65+
66+
Fantom disables the legacy shader context and lower precision raster pipeline,
67+
forces the raster pipeline blitter, and selects scalar high precision stages.
68+
Floating-point contraction is disabled with `-ffp-contract=off`. This avoids ARM
69+
and x86 optimized stages producing different rounded channel values when
70+
compositing the same pixels, including color emoji. These settings apply only to
71+
Fantom's Skia library.
72+
73+
The scalar selector is an optional `SK_RASTER_PIPELINE_FORCE_SCALAR` definition
74+
in the pinned Skia source. Gradle patches its prepared source copy, and CMake
75+
validates the selector without modifying the source. Keep the internal target's
76+
flags and the public GN flags aligned when changing Skia.

‎private/react-native-fantom/build.gradle.kts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,100 @@ val prepareNlohmannJson by
131131
into("$thirdParty/nlohmann_json")
132132
}
133133

134+
val boringsslVersion = "603b54d3d338efde4b84ece7be9aa410e44b1417"
135+
val boringsslArchive = File(downloadsDir, "boringssl-$boringsslVersion.tar.gz")
136+
val downloadBoringSSL by
137+
tasks.registering(Download::class) {
138+
dependsOn(createNativeDepsDirectories)
139+
src("https://github.com/google/boringssl/archive/$boringsslVersion.tar.gz")
140+
onlyIfModified(true)
141+
overwrite(false)
142+
retries(5)
143+
quiet(true)
144+
dest(boringsslArchive)
145+
}
146+
val prepareBoringSSL by
147+
tasks.registering(Copy::class) {
148+
dependsOn(downloadBoringSSL)
149+
from(tarTree(boringsslArchive))
150+
eachFile { path = path.substringAfter("/") }
151+
includeEmptyDirs = false
152+
into("$thirdParty/boringssl")
153+
}
154+
155+
// Match the CPU Skia revision used by the native tester in Buck.
156+
val skiaVersion = "3f8fc145ba678f3bd47af50f8fb51bcde474ef9c"
157+
158+
fun downloadSkiaSource(name: String, url: String, destination: File) =
159+
tasks.register<Download>("downloadSkia${name}") {
160+
dependsOn(createNativeDepsDirectories)
161+
src(url)
162+
onlyIfModified(true)
163+
overwrite(false)
164+
retries(5)
165+
quiet(true)
166+
dest(destination)
167+
}
168+
169+
val skiaArchive = File(downloadsDir, "skia-$skiaVersion.tar.gz")
170+
val downloadSkia =
171+
downloadSkiaSource(
172+
"Core",
173+
"https://github.com/google/skia/archive/$skiaVersion.tar.gz",
174+
skiaArchive,
175+
)
176+
val prepareSkiaSource by
177+
tasks.registering(Copy::class) {
178+
dependsOn(downloadSkia)
179+
from(tarTree(skiaArchive))
180+
eachFile { path = path.substringAfter("/") }
181+
filesMatching("**/SkRasterPipeline_opts.h") {
182+
filter { line ->
183+
line.replace(
184+
"#if !defined(__clang__)",
185+
"#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)",
186+
)
187+
}
188+
}
189+
includeEmptyDirs = false
190+
into("$thirdParty/skia")
191+
}
192+
193+
// GN builds these dependencies into libskia, including FreeType's color PNG glyph support.
194+
val skiaDependencies =
195+
mapOf(
196+
"freetype" to "https://github.com/freetype/freetype/archive/refs/tags/VER-2-14-2.tar.gz",
197+
"harfbuzz" to
198+
"https://chromium.googlesource.com/external/github.com/harfbuzz/harfbuzz.git/+archive/8d1b000a3edc90c12267b836b4ef3f81c0e53edc.tar.gz",
199+
"libpng" to "https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.55.tar.gz",
200+
"zlib" to
201+
"https://chromium.googlesource.com/chromium/src/third_party/zlib/+archive/c876c8f87101c5a75f6014b0f832499afeb65b73.tar.gz",
202+
)
203+
val prepareSkiaDependencies =
204+
skiaDependencies.map { (name, url) ->
205+
val archive = File(downloadsDir, "skia-$name-${url.substringAfterLast('/')}")
206+
val download = downloadSkiaSource(name.replaceFirstChar { it.uppercase() }, url, archive)
207+
tasks.register<Copy>("prepareSkia${name.replaceFirstChar { it.uppercase() }}") {
208+
dependsOn(download, prepareSkiaSource)
209+
from(tarTree(archive))
210+
if (name == "freetype" || name == "libpng") {
211+
eachFile { path = path.substringAfter("/") }
212+
}
213+
if (name == "zlib") {
214+
filesMatching("zutil.h") {
215+
filter { line ->
216+
line.replace(
217+
"defined(TARGET_OS_MAC)",
218+
"(defined(TARGET_OS_MAC) && !defined(__APPLE__))",
219+
)
220+
}
221+
}
222+
}
223+
includeEmptyDirs = false
224+
into("$thirdParty/skia/third_party/externals/$name")
225+
}
226+
}
227+
134228
var codegenSrcDir = File("$reactAndroidBuildDir/generated/source/codegen/jni")
135229
var codegenOutDir = File("$buildDir/codegen")
136230
val prepareRNCodegen by
@@ -166,6 +260,9 @@ val prepareNative3pDependencies by
166260
prepareGflags,
167261
prepareNlohmannJson,
168262
prepareFolly,
263+
prepareBoringSSL,
264+
prepareSkiaSource,
265+
*prepareSkiaDependencies.toTypedArray(),
169266
":packages:react-native:ReactAndroid:prepareBoost",
170267
":packages:react-native:ReactAndroid:prepareDoubleConversion",
171268
":packages:react-native:ReactAndroid:prepareFastFloat",
@@ -197,6 +294,7 @@ val configureFantomTester by
197294
"-DCMAKE_BUILD_TYPE=Debug",
198295
"-DFANTOM_CODEGEN_DIR=$buildDir/codegen",
199296
"-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party",
297+
"-DFANTOM_NINJA=$cmakePath/bin/ninja",
200298
"-DREACT_ANDROID_DIR=$reactAndroidDir",
201299
"-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon",
202300
"-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform",
@@ -215,6 +313,7 @@ val buildFantomTester by
215313
tasks.registering(CustomExecTask::class) {
216314
dependsOn(configureFantomTester)
217315
workingDir(testerDir)
316+
inputs.dir(testerDir)
218317
inputs.files(testerBuildOutputFileTree)
219318
commandLine(
220319
cmakeBinaryPath,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
/*::
12+
import type {AssetData} from 'metro/private/Assets';
13+
*/
14+
15+
module.exports = function assetPlugin /*:: <T extends AssetData> */(
16+
asset /*: T */,
17+
) /*: {...T, fantomFiles: Array<string>, ...} */ {
18+
return {...asset, fantomFiles: asset.files};
19+
};

‎private/react-native-fantom/config/metro-babel-transformer.flow.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ module.exports = {
5757
const cacheKeyParts = [
5858
MetroBabelTransformer.getCacheKey?.() ?? '',
5959
fs.readFileSync(__filename),
60+
fs.readFileSync(require.resolve('./assetPlugin')),
6061
fs.readFileSync(
6162
require.resolve('./babel-plugins/inject-debugger-statements-in-tests'),
6263
),

0 commit comments

Comments
 (0)