Skip to content

Commit 2e16a2e

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. Reviewed By: andrewdacenko Differential Revision: D126888503
1 parent 98291bc commit 2e16a2e

31 files changed

Lines changed: 1639 additions & 19 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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ values. If another root is still alive and the new metrics differ from the
9999
current global dimensions, Fantom applies the new values and emits a warning.
100100
Call `root.destroy()` when a root is 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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 Fantom dependencies installed by the repository's normal Yarn
22+
setup. Their font licenses are compatible with React Native distribution; the
23+
packages retain the Bitstream Vera/DejaVu and SIL Open Font License notices.
24+
Registration runs once during Fantom's default environment setup. Tests that
25+
configure React Native directly call `setUpFantomEnvironment()` themselves. No
26+
host font installation or 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. BoringSSL provides SHA-256 for screenshot pixel hashes; the existing
45+
Boost dependency has SHA-1 and MD5 but no SHA-256 implementation. GN bootstrap
46+
uses Python 3. The usual JDK, Android SDK, C/C++ compiler and Python build tools
47+
are required; graphics, crypto and font libraries come from these prepared
48+
sources or bundled assets.
49+
50+
The native Gradle task tracks C++ sources so edits rebuild the tester.
51+
Dependency versions and download locations are defined in
52+
[`build.gradle.kts`](../build.gradle.kts). Native integration is configured in
53+
[`tester/CMakeLists.txt`](../tester/CMakeLists.txt) and its third-party
54+
subdirectories.
55+
56+
### Consistent raster configuration
57+
58+
Tests share snapshot baselines across Linux and macOS. Different font metrics
59+
can change line wrapping and the placement of other views; different raster
60+
rounding can change pixel hashes even when the images look alike. Keeping both
61+
consistent makes a failing snapshot useful evidence of a rendering change,
62+
independent of the machine running the test.
63+
64+
Both native builds use CPU Skia, FreeType and the same bundled fonts. Host font
65+
managers and platform shaping backends are disabled. Text gamma correction is
66+
configured identically in both builds.
67+
68+
Fantom disables the legacy shader context and lower precision raster pipeline,
69+
forces the raster pipeline blitter, and selects scalar high precision stages.
70+
Floating-point contraction is disabled with `-ffp-contract=off`. This avoids ARM
71+
and x86 optimized stages producing different rounded channel values when
72+
compositing the same pixels, including color emoji. These settings apply only to
73+
Fantom's Skia library.
74+
75+
The scalar selector is an optional `SK_RASTER_PIPELINE_FORCE_SCALAR` definition
76+
in the pinned Skia source. Gradle patches its prepared source copy, and CMake
77+
validates the selector without modifying the source. Keep the internal target's
78+
flags and the public GN flags aligned when changing Skia.

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,114 @@ 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+
from("tester/third-party/skia/CMakeLists.txt")
181+
eachFile { path = path.substringAfter("/") }
182+
filesMatching("**/SkRasterPipeline_opts.h") {
183+
filter { line ->
184+
line.replace(
185+
"#if !defined(__clang__)",
186+
"#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)",
187+
)
188+
}
189+
}
190+
filesMatching("**/SkBlitter_Sprite.cpp") {
191+
filter { line ->
192+
line
193+
.replace(
194+
" if (gSkForceRasterPipelineBlitter) {",
195+
"#if !defined(SK_FORCE_RASTER_PIPELINE_BLITTER)\n if (gSkForceRasterPipelineBlitter) {",
196+
)
197+
.replace(
198+
" if (!blitter && !paint.getMaskFilter()) {",
199+
"#endif\n if (!blitter && !paint.getMaskFilter()) {",
200+
)
201+
}
202+
}
203+
includeEmptyDirs = false
204+
into("$thirdParty/skia")
205+
}
206+
207+
// GN builds these dependencies into libskia, including FreeType's color PNG glyph support.
208+
val skiaDependencies =
209+
mapOf(
210+
"freetype" to "https://github.com/freetype/freetype/archive/refs/tags/VER-2-14-2.tar.gz",
211+
"harfbuzz" to
212+
"https://chromium.googlesource.com/external/github.com/harfbuzz/harfbuzz.git/+archive/8d1b000a3edc90c12267b836b4ef3f81c0e53edc.tar.gz",
213+
"libpng" to "https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.55.tar.gz",
214+
"zlib" to
215+
"https://chromium.googlesource.com/chromium/src/third_party/zlib/+archive/c876c8f87101c5a75f6014b0f832499afeb65b73.tar.gz",
216+
)
217+
val prepareSkiaDependencies =
218+
skiaDependencies.map { (name, url) ->
219+
val archive = File(downloadsDir, "skia-$name-${url.substringAfterLast('/')}")
220+
val download = downloadSkiaSource(name.replaceFirstChar { it.uppercase() }, url, archive)
221+
tasks.register<Copy>("prepareSkia${name.replaceFirstChar { it.uppercase() }}") {
222+
dependsOn(download, prepareSkiaSource)
223+
from(tarTree(archive))
224+
if (name == "freetype" || name == "libpng") {
225+
eachFile { path = path.substringAfter("/") }
226+
}
227+
if (name == "zlib") {
228+
filesMatching("zutil.h") {
229+
filter { line ->
230+
line.replace(
231+
"defined(TARGET_OS_MAC)",
232+
"(defined(TARGET_OS_MAC) && !defined(__APPLE__))",
233+
)
234+
}
235+
}
236+
}
237+
includeEmptyDirs = false
238+
into("$thirdParty/skia/third_party/externals/$name")
239+
}
240+
}
241+
134242
var codegenSrcDir = File("$reactAndroidBuildDir/generated/source/codegen/jni")
135243
var codegenOutDir = File("$buildDir/codegen")
136244
val prepareRNCodegen by
@@ -166,6 +274,9 @@ val prepareNative3pDependencies by
166274
prepareGflags,
167275
prepareNlohmannJson,
168276
prepareFolly,
277+
prepareBoringSSL,
278+
prepareSkiaSource,
279+
*prepareSkiaDependencies.toTypedArray(),
169280
":packages:react-native:ReactAndroid:prepareBoost",
170281
":packages:react-native:ReactAndroid:prepareDoubleConversion",
171282
":packages:react-native:ReactAndroid:prepareFastFloat",
@@ -197,6 +308,7 @@ val configureFantomTester by
197308
"-DCMAKE_BUILD_TYPE=Debug",
198309
"-DFANTOM_CODEGEN_DIR=$buildDir/codegen",
199310
"-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party",
311+
"-DFANTOM_NINJA=$cmakePath/bin/ninja",
200312
"-DREACT_ANDROID_DIR=$reactAndroidDir",
201313
"-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon",
202314
"-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform",
@@ -215,6 +327,7 @@ val buildFantomTester by
215327
tasks.registering(CustomExecTask::class) {
216328
dependsOn(configureFantomTester)
217329
workingDir(testerDir)
330+
inputs.dir(testerDir)
218331
inputs.files(testerBuildOutputFileTree)
219332
commandLine(
220333
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)