diff --git a/.flowconfig b/.flowconfig index 22d54b035acf..477771c0eba7 100644 --- a/.flowconfig +++ b/.flowconfig @@ -68,7 +68,7 @@ module.name_mapper='^react-native/src/fb_internal/\(.*\)$' -> '/pa # `src/fb_internal/*` stay importable. module.name_mapper='^react-native/\(Libraries\|src/private\|src/types\)/.*$' -> 'react-native-deep-import-is-not-part-of-the-public-api' module.name_mapper='^@react-native/dev-middleware$' -> '/packages/dev-middleware' -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\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' +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\)$' -> '/packages/react-native/Libraries/Image/RelativeImageStub' module.system.haste.module_ref_prefix=m# diff --git a/package.json b/package.json index 5dadfdde6321..ecbed667966d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-flow": "^7.24.7", "@electron/packager": "^20.0.0", + "@expo-google-fonts/noto-color-emoji": "0.4.7", "@expo/spawn-async": "^1.7.2", "@jest/create-cache-key-function": "^29.7.0", "@jest/test-sequencer": "^29.7.0", @@ -82,6 +83,7 @@ "connect": "^3.6.5", "debug": "^4.4.0", "deep-equal": "1.1.1", + "dejavu-fonts-ttf": "2.37.3", "eslint": "^8.57.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-babel": "^5.3.1", diff --git a/packages/react-native/Libraries/Image/ImageTypes.flow.js b/packages/react-native/Libraries/Image/ImageTypes.flow.js index 13f1106f66d0..eb5e03e2e152 100644 --- a/packages/react-native/Libraries/Image/ImageTypes.flow.js +++ b/packages/react-native/Libraries/Image/ImageTypes.flow.js @@ -12,7 +12,7 @@ import type {HostInstance} from '../..'; import type {RootTag} from '../Types/RootTagTypes'; import type {ResolvedAssetSource} from './AssetSourceResolver'; import type {ImageProps as ImagePropsType} from './ImageProps'; -import type {ImageSource} from './ImageSource'; +import typeof resolveAssetSource from './resolveAssetSource'; import * as React from 'react'; @@ -59,7 +59,7 @@ type ImageComponentStaticsIOS = Readonly<{ /** * @see https://reactnative.dev/docs/image#resolveassetsource */ - resolveAssetSource(source: ImageSource): ?ImageResolvedAssetSource, + resolveAssetSource: resolveAssetSource, }>; type ImageComponentStaticsAndroid = Readonly<{ diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index c0f8309f0ac5..4dc11bc1a41f 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -75,6 +75,7 @@ declare const $$index: { } declare const $$NativeDeviceInfo: typeof NativeModule_default declare const $$ReactFabric: typeof ReactFabric_default +declare const $$resolveAssetSource: typeof resolveAssetSource_default declare const $$ScrollViewContext: typeof ScrollViewContext_default declare const absoluteFill: AbsoluteFillStyle declare const AccessibilityInfo: typeof AccessibilityInfo_default @@ -917,6 +918,7 @@ declare type $$flattenStyle = typeof $$flattenStyle declare type $$index = typeof $$index declare type $$NativeDeviceInfo = typeof $$NativeDeviceInfo declare type $$ReactFabric = typeof $$ReactFabric +declare type $$resolveAssetSource = typeof $$resolveAssetSource declare type $$ScrollViewContext = typeof $$ScrollViewContext declare type absoluteFill = typeof absoluteFill declare type AbsoluteFillStyle = { @@ -2437,6 +2439,7 @@ declare type ImageComponentStaticsAndroid = Readonly< } > declare type ImageComponentStaticsIOS = { + readonly resolveAssetSource: resolveAssetSource getSize(uri: string): Promise getSize( uri: string, @@ -2466,7 +2469,6 @@ declare type ImageComponentStaticsIOS = { queryCache(urls: Array): Promise<{ [url: string]: "disk" | "disk/memory" | "memory" }> - resolveAssetSource(source: ImageSource): ImageResolvedAssetSource | undefined } declare type ImageErrorEvent = NativeSyntheticEvent< Readonly @@ -4116,6 +4118,10 @@ declare type RequiredVirtualizedSectionListProps< sections: ReadonlyArray> } declare type requireNativeComponent = typeof requireNativeComponent +declare type resolveAssetSource = typeof $$resolveAssetSource +declare function resolveAssetSource_default( + source: ImageSource | null | undefined, +): null | ResolvedAssetSource | undefined declare type ResolvedAssetSource = { readonly height: number | undefined readonly scale: number @@ -5791,7 +5797,7 @@ export { AlertOptions, // 8a116d2a AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 33fe7464 + Animated, // adba9272 AppConfig, // 35c0ca70 AppRegistry, // edec2a30 AppState, // 12012be5 @@ -5856,10 +5862,10 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a24a5777 - ImageBackground, // 1fbbacef - ImageBackgroundInstance, // 9c900a60 - ImageBackgroundProps, // b078f0ac + Image, // 404e5c9b + ImageBackground, // d2afc8de + ImageBackgroundInstance, // 2e9ed91b + ImageBackgroundProps, // d49fc98c ImageErrorEvent, // b6227714 ImageInstance, // df709524 ImageLoadEvent, // 31709c38 diff --git a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js index 6f40faabb9c1..3b7da0378b39 100644 --- a/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js +++ b/packages/react-native/src/private/testing/fantom/specs/NativeFantom.js @@ -116,6 +116,8 @@ interface Spec extends TurboModule { surfaceId: RootTag, config: RenderFormatOptions, ) => string; + registerAsset(uri: string, filePath: string): void; + registerFont(filePath: string): void; reportTestSuiteResultsJSON: (results: string) => void; createShadowNodeReferenceCounter( shadowNode: unknown /* ShadowNode */, diff --git a/private/react-native-fantom/__docs__/README.md b/private/react-native-fantom/__docs__/README.md index 0a6d73d07e5d..d59edb57e937 100644 --- a/private/react-native-fantom/__docs__/README.md +++ b/private/react-native-fantom/__docs__/README.md @@ -99,6 +99,11 @@ values. If another root is still alive and the new metrics differ from the current global dimensions, Fantom applies the new values and emits a warning. Call `root.destroy()` when a root is no longer needed. +Each root also uses its `fontScale` for native text and text-input measurement, +including line heights and wrapping. `allowFontScaling={false}` opts a component +out, and `maxFontSizeMultiplier` caps its scale. Existing roots retain their own +native font scale when a new root changes the global display metrics. + For a full API reference, please see the [inline documentation](../src/index.js) defined for the methods in the `@react-native/fantom` [module](../src/index.js). diff --git a/private/react-native-fantom/__docs__/rendering.md b/private/react-native-fantom/__docs__/rendering.md new file mode 100644 index 000000000000..8b6d7c87b195 --- /dev/null +++ b/private/react-native-fantom/__docs__/rendering.md @@ -0,0 +1,78 @@ +# Rendering + +## Text measurement + +Fantom always measures intrinsic `Text` and `TextInput` sizes with Skia's CPU +font metrics. Layout and screenshot painting share the same text layout code, +including fragment styles, constrained-width wrapping, inline attachments, input +values and placeholders. `maximumNumberOfLines` limits the measured box; clipped +attachments retain their entries in Fabric's attachment mapping. + +A root's `fontScale` defaults to `1` and is passed to native layout. Text and +inputs respect `allowFontScaling` and `maxFontSizeMultiplier`; line heights +scale with the font size. `devicePixelRatio` determines raster resolution, +rather than the logical text size. + +### Fonts and assets + +Fantom registers DejaVu Sans regular, bold, italic and bold italic faces from +[`dejavu-fonts-ttf`](https://www.npmjs.com/package/dejavu-fonts-ttf), plus +[`@expo-google-fonts/noto-color-emoji`](https://www.npmjs.com/package/@expo-google-fonts/noto-color-emoji). +Both are pinned Fantom dependencies installed by the repository's normal Yarn +setup. Their font licenses are compatible with React Native distribution; the +packages retain the Bitstream Vera/DejaVu and SIL Open Font License notices. +Registration runs once during Fantom's default environment setup. Tests that +configure React Native directly call `setUpFantomEnvironment()` themselves. No +host font installation or Expo runtime is required. + +Metro preserves the source files of bundled assets in Fantom's asset metadata. +The shared resolver selects the asset variant for the root's pixel ratio and +registers its native file path. Ordinary `require()` sources therefore work +without custom path mappings. Keep a fixture alongside the test that uses it. + +Default color emoji selection follows Unicode 16.0 `Emoji_Presentation`. +Explicit text and emoji variation selectors override that default. Selectors and +keycap sequences stay with their bases during fallback and wrapping; HarfBuzz +shapes color emoji sequences for both measurement and painting. General script +shaping, text decorations and ellipsis are not implemented. + +### Public native build + +`yarn workspace @react-native/fantom build` uses Gradle to prepare pinned Skia, +FreeType, libpng, zlib, HarfBuzz and BoringSSL sources. CMake builds HarfBuzz +and BoringSSL as static libraries, and builds Skia with GN and the Android SDK's +Ninja. BoringSSL provides SHA-256 for screenshot pixel hashes; the existing +Boost dependency has SHA-1 and MD5 but no SHA-256 implementation. GN bootstrap +uses Python 3. The usual JDK, Android SDK, C/C++ compiler and Python build tools +are required; graphics, crypto and font libraries come from these prepared +sources or bundled assets. + +The native Gradle task tracks C++ sources so edits rebuild the tester. +Dependency versions and download locations are defined in +[`build.gradle.kts`](../build.gradle.kts). Native integration is configured in +[`tester/CMakeLists.txt`](../tester/CMakeLists.txt) and its third-party +subdirectories. + +### Consistent raster configuration + +Tests share snapshot baselines across Linux and macOS. Different font metrics +can change line wrapping and the placement of other views; different raster +rounding can change pixel hashes even when the images look alike. Keeping both +consistent makes a failing snapshot useful evidence of a rendering change, +independent of the machine running the test. + +Both native builds use CPU Skia, FreeType and the same bundled fonts. Host font +managers and platform shaping backends are disabled. Text gamma correction is +configured identically in both builds. + +Fantom disables the legacy shader context and lower precision raster pipeline, +forces the raster pipeline blitter, and selects scalar high precision stages. +Floating-point contraction is disabled with `-ffp-contract=off`. This avoids ARM +and x86 optimized stages producing different rounded channel values when +compositing the same pixels, including color emoji. These settings apply only to +Fantom's Skia library. + +The scalar selector is an optional `SK_RASTER_PIPELINE_FORCE_SCALAR` definition +in the pinned Skia source. Gradle patches its prepared source copy, and CMake +validates the selector without modifying the source. Keep the internal target's +flags and the public GN flags aligned when changing Skia. diff --git a/private/react-native-fantom/build.gradle.kts b/private/react-native-fantom/build.gradle.kts index d253b8f14f99..6f0f88a7543f 100644 --- a/private/react-native-fantom/build.gradle.kts +++ b/private/react-native-fantom/build.gradle.kts @@ -131,6 +131,114 @@ val prepareNlohmannJson by into("$thirdParty/nlohmann_json") } +val boringsslVersion = "603b54d3d338efde4b84ece7be9aa410e44b1417" +val boringsslArchive = File(downloadsDir, "boringssl-$boringsslVersion.tar.gz") +val downloadBoringSSL by + tasks.registering(Download::class) { + dependsOn(createNativeDepsDirectories) + src("https://github.com/google/boringssl/archive/$boringsslVersion.tar.gz") + onlyIfModified(true) + overwrite(false) + retries(5) + quiet(true) + dest(boringsslArchive) + } +val prepareBoringSSL by + tasks.registering(Copy::class) { + dependsOn(downloadBoringSSL) + from(tarTree(boringsslArchive)) + eachFile { path = path.substringAfter("/") } + includeEmptyDirs = false + into("$thirdParty/boringssl") + } + +// Match the CPU Skia revision used by the native tester in Buck. +val skiaVersion = "3f8fc145ba678f3bd47af50f8fb51bcde474ef9c" + +fun downloadSkiaSource(name: String, url: String, destination: File) = + tasks.register("downloadSkia${name}") { + dependsOn(createNativeDepsDirectories) + src(url) + onlyIfModified(true) + overwrite(false) + retries(5) + quiet(true) + dest(destination) + } + +val skiaArchive = File(downloadsDir, "skia-$skiaVersion.tar.gz") +val downloadSkia = + downloadSkiaSource( + "Core", + "https://github.com/google/skia/archive/$skiaVersion.tar.gz", + skiaArchive, + ) +val prepareSkiaSource by + tasks.registering(Copy::class) { + dependsOn(downloadSkia) + from(tarTree(skiaArchive)) + from("tester/third-party/skia/CMakeLists.txt") + eachFile { path = path.substringAfter("/") } + filesMatching("**/SkRasterPipeline_opts.h") { + filter { line -> + line.replace( + "#if !defined(__clang__)", + "#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)", + ) + } + } + filesMatching("**/SkBlitter_Sprite.cpp") { + filter { line -> + line + .replace( + " if (gSkForceRasterPipelineBlitter) {", + "#if !defined(SK_FORCE_RASTER_PIPELINE_BLITTER)\n if (gSkForceRasterPipelineBlitter) {", + ) + .replace( + " if (!blitter && !paint.getMaskFilter()) {", + "#endif\n if (!blitter && !paint.getMaskFilter()) {", + ) + } + } + includeEmptyDirs = false + into("$thirdParty/skia") + } + +// GN builds these dependencies into libskia, including FreeType's color PNG glyph support. +val skiaDependencies = + mapOf( + "freetype" to "https://github.com/freetype/freetype/archive/refs/tags/VER-2-14-2.tar.gz", + "harfbuzz" to + "https://chromium.googlesource.com/external/github.com/harfbuzz/harfbuzz.git/+archive/8d1b000a3edc90c12267b836b4ef3f81c0e53edc.tar.gz", + "libpng" to "https://github.com/pnggroup/libpng/archive/refs/tags/v1.6.55.tar.gz", + "zlib" to + "https://chromium.googlesource.com/chromium/src/third_party/zlib/+archive/c876c8f87101c5a75f6014b0f832499afeb65b73.tar.gz", + ) +val prepareSkiaDependencies = + skiaDependencies.map { (name, url) -> + val archive = File(downloadsDir, "skia-$name-${url.substringAfterLast('/')}") + val download = downloadSkiaSource(name.replaceFirstChar { it.uppercase() }, url, archive) + tasks.register("prepareSkia${name.replaceFirstChar { it.uppercase() }}") { + dependsOn(download, prepareSkiaSource) + from(tarTree(archive)) + if (name == "freetype" || name == "libpng") { + eachFile { path = path.substringAfter("/") } + } + if (name == "zlib") { + filesMatching("zutil.h") { + filter { line -> + line.replace( + "defined(TARGET_OS_MAC)", + "(defined(TARGET_OS_MAC) && !defined(__APPLE__))", + ) + } + } + } + includeEmptyDirs = false + into("$thirdParty/skia/third_party/externals/$name") + } + } + var codegenSrcDir = File("$reactAndroidBuildDir/generated/source/codegen/jni") var codegenOutDir = File("$buildDir/codegen") val prepareRNCodegen by @@ -166,6 +274,9 @@ val prepareNative3pDependencies by prepareGflags, prepareNlohmannJson, prepareFolly, + prepareBoringSSL, + prepareSkiaSource, + *prepareSkiaDependencies.toTypedArray(), ":packages:react-native:ReactAndroid:prepareBoost", ":packages:react-native:ReactAndroid:prepareDoubleConversion", ":packages:react-native:ReactAndroid:prepareFastFloat", @@ -197,6 +308,7 @@ val configureFantomTester by "-DCMAKE_BUILD_TYPE=Debug", "-DFANTOM_CODEGEN_DIR=$buildDir/codegen", "-DFANTOM_THIRD_PARTY_DIR=$buildDir/third-party", + "-DFANTOM_NINJA=$cmakePath/bin/ninja", "-DREACT_ANDROID_DIR=$reactAndroidDir", "-DREACT_COMMON_DIR=$reactNativeDir/ReactCommon", "-DREACT_CXX_PLATFORM_DIR=$reactNativeDir/ReactCxxPlatform", @@ -215,6 +327,7 @@ val buildFantomTester by tasks.registering(CustomExecTask::class) { dependsOn(configureFantomTester) workingDir(testerDir) + inputs.dir(testerDir) inputs.files(testerBuildOutputFileTree) commandLine( cmakeBinaryPath, diff --git a/private/react-native-fantom/config/assetPlugin.js b/private/react-native-fantom/config/assetPlugin.js new file mode 100644 index 000000000000..65c13bed15d0 --- /dev/null +++ b/private/react-native-fantom/config/assetPlugin.js @@ -0,0 +1,19 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +/*:: +import type {AssetData} from 'metro/private/Assets'; +*/ + +module.exports = function assetPlugin /*:: */( + asset /*: T */, +) /*: {...T, fantomFiles: Array, ...} */ { + return {...asset, fantomFiles: asset.files}; +}; diff --git a/private/react-native-fantom/config/metro-babel-transformer.flow.js b/private/react-native-fantom/config/metro-babel-transformer.flow.js index 1b141d0707b1..cb86d07f3710 100644 --- a/private/react-native-fantom/config/metro-babel-transformer.flow.js +++ b/private/react-native-fantom/config/metro-babel-transformer.flow.js @@ -57,6 +57,7 @@ module.exports = { const cacheKeyParts = [ MetroBabelTransformer.getCacheKey?.() ?? '', fs.readFileSync(__filename), + fs.readFileSync(require.resolve('./assetPlugin')), fs.readFileSync( require.resolve('./babel-plugins/inject-debugger-statements-in-tests'), ), diff --git a/private/react-native-fantom/config/metro.config.js b/private/react-native-fantom/config/metro.config.js index e6398adb7212..87a0a4a9f21a 100644 --- a/private/react-native-fantom/config/metro.config.js +++ b/private/react-native-fantom/config/metro.config.js @@ -55,6 +55,7 @@ const config /*: InputConfigT */ = { : null, }, transformer: { + assetPlugins: [require.resolve('./assetPlugin')], // We need to wrap the default transformer so we can run it from source // using babel-register. babelTransformerPath: path.resolve(__dirname, 'metro-babel-transformer.js'), diff --git a/private/react-native-fantom/package.json b/private/react-native-fantom/package.json index f0f965fbba23..d670926f5e0b 100644 --- a/private/react-native-fantom/package.json +++ b/private/react-native-fantom/package.json @@ -8,7 +8,9 @@ "build": "./build.sh" }, "dependencies": { - "babel-plugin-istanbul": "^7.0.0" + "@expo-google-fonts/noto-color-emoji": "0.4.7", + "babel-plugin-istanbul": "^7.0.0", + "dejavu-fonts-ttf": "2.37.3" }, "peerDependencies": { "jest": "^29.7.0", diff --git a/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js b/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js new file mode 100644 index 000000000000..4c76030ec5e4 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/FantomTextMeasurement-itest.js @@ -0,0 +1,197 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type {HostInstance} from 'react-native'; + +import NativeFantom from '../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import * as Fantom from '@react-native/fantom'; +import nullthrows from 'nullthrows'; +import {createRef} from 'react'; +import {Text, TextInput, View} from 'react-native'; + +describe('Fantom text measurement', () => { + it('reports invalid font files', () => { + expect(() => NativeFantom.registerFont('/missing/fantom-font.ttf')).toThrow( + 'Cannot register Fantom font: /missing/fantom-font.ttf', + ); + }); + + it('measures intrinsic text and input sizes', () => { + const root = Fantom.createRoot(); + const textRef = createRef(); + const inputRef = createRef(); + const elements = ( + + Hello Fantom + + + ); + Fantom.runTask(() => root.render(elements)); + for (const ref of [textRef, inputRef]) { + const bounds = nullthrows(ref.current).getBoundingClientRect(); + expect(bounds.width).toBeGreaterThan(40); + expect(bounds.height).toBeGreaterThan(10); + } + root.destroy(); + }); + + it('wraps text with a constrained width', () => { + const root = Fantom.createRoot(); + const singleLineRef = createRef(); + const wrappedRef = createRef(); + const text = 'Fantom measures text across multiple lines.'; + Fantom.runTask(() => + root.render( + + {text} + + {text} + + , + ), + ); + const singleLine = nullthrows( + singleLineRef.current, + ).getBoundingClientRect(); + const wrapped = nullthrows(wrappedRef.current).getBoundingClientRect(); + expect(wrapped.width).toBe(80); + expect(wrapped.height).toBeGreaterThan(singleLine.height); + root.destroy(); + }); + + it('scales text, input values, placeholders, and explicit line heights', () => { + const sizes: Array> = []; + for (const fontScale of [1, 2]) { + const root = Fantom.createRoot({fontScale, devicePixelRatio: 4}); + const refs = Array.from({length: 4}, () => createRef()); + const style = {fontSize: 16, lineHeight: 24}; + Fantom.runTask(() => + root.render( + + + Fantom + + + + + , + ), + ); + sizes.push( + refs.map(ref => { + const {width, height} = nullthrows( + ref.current, + ).getBoundingClientRect(); + return {width, height}; + }), + ); + root.destroy(); + } + sizes[0].forEach((size, index) => { + expect(size.width).toBe(61); + expect(size.height).toBe(index === 3 ? 48 : 24); + expect(sizes[1][index].width).toBe(121.75); + expect(sizes[1][index].height).toBe(index === 3 ? 96 : 48); + }); + }); + + it('respects scaling opt-outs and caps for text and inputs', () => { + const root = Fantom.createRoot({fontScale: 2, devicePixelRatio: 4}); + const refs = Array.from({length: 6}, () => createRef()); + const style = {fontSize: 16, lineHeight: 24}; + Fantom.runTask(() => + root.render( + + + Fantom + + + Fantom + + + Fantom + + + + + , + ), + ); + const sizes = refs.map(ref => + nullthrows(ref.current).getBoundingClientRect(), + ); + for (const offset of [0, 3]) { + expect(sizes[offset].height).toBe(24); + expect(sizes[offset + 1].height).toBe(36); + expect(sizes[offset + 2].height).toBe(48); + expect(sizes[offset + 1].width).toBeCloseTo(sizes[offset].width * 1.5, 0); + expect(sizes[offset + 2].width).toBeCloseTo(sizes[offset].width * 2, 0); + } + root.destroy(); + }); + + it('keeps native font scale per root when global metrics change', () => { + const first = Fantom.createRoot({fontScale: 1}); + const firstRef = createRef(); + const secondRef = createRef(); + const style = {width: 120, fontSize: 16, lineHeight: 24}; + const text = 'Fantom measures text across multiple lines.'; + Fantom.runTask(() => + first.render( + + {text} + , + ), + ); + const before = nullthrows(firstRef.current).getBoundingClientRect(); + const second = Fantom.createRoot({fontScale: 2}); + Fantom.runTask(() => { + second.render( + + {text} + , + ); + first.render( + + {text}! + , + ); + }); + const after = nullthrows(firstRef.current).getBoundingClientRect(); + const scaled = nullthrows(secondRef.current).getBoundingClientRect(); + expect(after.height).toBe(before.height); + expect(scaled.width).toBe(before.width); + expect(scaled.height).toBeGreaterThan(before.height * 2); + first.destroy(); + second.destroy(); + }); +}); diff --git a/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js b/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js new file mode 100644 index 000000000000..195614dd82d6 --- /dev/null +++ b/private/react-native-fantom/src/__tests__/assetSourceResolver-test.js @@ -0,0 +1,139 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import PixelRatio from '../../../../packages/react-native/Libraries/Utilities/PixelRatio'; +import {AssetRegistry} from '../../../../packages/react-native/src/private/assets/AssetRegistry'; +import assetPlugin from '../../config/assetPlugin'; +import {Image} from 'react-native'; + +const NativeFantom = + require('../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom').default; +const setUpAssetSourceResolver = require('../setUpAssetSourceResolver').default; + +const resolveAssetSource = Image.resolveAssetSource; + +jest.mock( + '../../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom', + () => ({__esModule: true, default: {registerAsset: jest.fn()}}), +); + +const asset = { + __packager_asset: true, + fileSystemLocation: '/fixtures', + httpServerLocation: '/assets/fixtures', + width: 12, + height: 7, + scales: [1, 2, 3], + files: [ + '/fixtures/logo.png', + '/fixtures/logo@2x.android.png', + '/fixtures/logo@3x.png', + ], + hash: 'test', + name: 'logo', + type: 'png', + resolver: undefined, +}; + +beforeEach(() => { + jest.clearAllMocks(); + resolveAssetSource.setCustomSourceTransformer(() => null); + setUpAssetSourceResolver(); + jest.spyOn(PixelRatio, 'get').mockReturnValue(2); +}); + +afterEach(() => { + jest.restoreAllMocks(); + resolveAssetSource.setCustomSourceTransformer(() => null); +}); + +it.each([ + [1, 1, '/fixtures/logo.png'], + [1.5, 2, '/fixtures/logo@2x.android.png'], + [4, 3, '/fixtures/logo@3x.png'], +])( + 'resolves the bundled file at device scale %s', + (deviceScale, scale, file) => { + jest.spyOn(PixelRatio, 'get').mockReturnValue(deviceScale); + const id = AssetRegistry.registerAsset(assetPlugin(asset)); + const source = resolveAssetSource(id); + expect(source?.scale).toBe(scale); + expect(source).toEqual( + resolveAssetSource(AssetRegistry.registerAsset(asset)), + ); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith(source?.uri, file); + }, +); + +it('distinguishes assets with the same name in different directories', () => { + const first = AssetRegistry.registerAsset(assetPlugin(asset)); + const second = AssetRegistry.registerAsset( + assetPlugin({ + ...asset, + httpServerLocation: '/assets/other', + files: ['/other/logo.png'], + scales: [1], + }), + ); + const firstUri = resolveAssetSource(first)?.uri; + const secondUri = resolveAssetSource(second)?.uri; + expect(firstUri).not.toBe(secondUri); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith( + firstUri, + '/fixtures/logo@2x.android.png', + ); + expect(NativeFantom.registerAsset).toHaveBeenCalledWith( + secondUri, + '/other/logo.png', + ); +}); + +it('reports a missing file for the selected scale', () => { + const id = AssetRegistry.registerAsset( + assetPlugin({...asset, files: ['/fixtures/logo.png']}), + ); + expect(() => resolveAssetSource(id)).toThrow( + 'Fantom could not find a bundled file for logo.png at scale 2.', + ); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('reports assets with no scales', () => { + const id = AssetRegistry.registerAsset(assetPlugin({...asset, scales: []})); + expect(() => resolveAssetSource(id)).toThrow( + 'Fantom could not find a bundled file for logo.png at scale 1.', + ); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('preserves explicit image sources', () => { + const source = {uri: '/images/logo.png', width: 12, height: 7}; + expect(resolveAssetSource(source)).toBe(source); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('uses normal resolution for assets without Fantom metadata', () => { + const id = AssetRegistry.registerAsset(asset); + resolveAssetSource.setCustomSourceTransformer(() => null); + const expected = resolveAssetSource(id); + setUpAssetSourceResolver(); + expect(resolveAssetSource(id)).toEqual(expected); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); + +it('respects an existing custom source transformer', () => { + resolveAssetSource.setCustomSourceTransformer(resolver => + resolver.fromSource('custom-source'), + ); + setUpAssetSourceResolver(); + const id = AssetRegistry.registerAsset(assetPlugin(asset)); + expect(resolveAssetSource(id)?.uri).toBe('custom-source'); + expect(NativeFantom.registerAsset).not.toHaveBeenCalled(); +}); diff --git a/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js b/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js new file mode 100644 index 000000000000..7f2bee97025b --- /dev/null +++ b/private/react-native-fantom/src/__tests__/benchmarks/TextMeasurement-benchmark-itest.js @@ -0,0 +1,95 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {Text, TextInput, View} from 'react-native'; + +type Kind = 'short text' | 'wrapped text' | 'input' | 'multiline input'; +type BenchmarkCase = { + kind: Kind, + count: number, +}; + +const kinds: Array = [ + 'short text', + 'wrapped text', + 'input', + 'multiline input', +]; +const cases: Array = []; +for (const kind of kinds) { + for (const count of [100, 1000]) { + cases.push({kind, count}); + } +} + +const paragraph = + 'Fantom measures text using the same font metrics as its screenshot painter. This paragraph wraps across several lines without an explicit height.'; + +function createElements(kind: Kind, count: number): React.MixedElement { + const createElement = { + 'short text': (i: number) => {`Text instance ${i}`}, + 'wrapped text': (i: number) => ( + {`${paragraph} ${i}`} + ), + input: (i: number) => , + 'multiline input': (i: number) => ( + + ), + }[kind]; + return ( + + {Array.from({length: count}, (_, i) => createElement(i))} + + ); +} + +let root; +let elements: React.MixedElement; + +Fantom.unstable_benchmark + .suite('Text measurement') + .test.each( + cases, + ({kind, count}) => `render ${count} ${kind} components`, + () => { + Fantom.runTask(() => root.render(elements)); + }, + ({kind, count}) => ({ + beforeAll: () => { + elements = createElements(kind, count); + }, + beforeEach: () => { + root = Fantom.createRoot(); + }, + afterEach: () => { + root.destroy(); + }, + }), + ) + .test( + 'create root', + () => { + root = Fantom.createRoot(); + }, + { + afterEach: () => { + root.destroy(); + }, + }, + ); diff --git a/private/react-native-fantom/src/setUpAssetSourceResolver.js b/private/react-native-fantom/src/setUpAssetSourceResolver.js new file mode 100644 index 000000000000..5b1144c0c327 --- /dev/null +++ b/private/react-native-fantom/src/setUpAssetSourceResolver.js @@ -0,0 +1,38 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import NativeFantom from '../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import {Image} from 'react-native'; + +export default function setUpAssetSourceResolver(): void { + const resolveAssetSource = Image.resolveAssetSource; + resolveAssetSource.addCustomSourceTransformer(resolver => { + const asset: unknown = resolver.asset; + if ( + asset == null || + typeof asset !== 'object' || + !('fantomFiles' in asset) || + !Array.isArray(asset.fantomFiles) + ) { + return null; + } + const files = asset.fantomFiles; + const scale = resolveAssetSource.pickScale(resolver.asset.scales); + const file = files[resolver.asset.scales.indexOf(scale)]; + if (typeof file !== 'string') { + throw new Error( + `Fantom could not find a bundled file for ${resolver.asset.name}.${resolver.asset.type} at scale ${scale}.`, + ); + } + const source = resolver.defaultAsset(); + NativeFantom.registerAsset(source.uri, file); + return source; + }); +} diff --git a/private/react-native-fantom/src/setUpDefaultFonts.js b/private/react-native-fantom/src/setUpDefaultFonts.js new file mode 100644 index 000000000000..5994539046d2 --- /dev/null +++ b/private/react-native-fantom/src/setUpDefaultFonts.js @@ -0,0 +1,31 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import NativeFantom from '../../../packages/react-native/src/private/testing/fantom/specs/NativeFantom'; +import nullthrows from 'nullthrows'; +import {Image} from 'react-native'; + +export default function setUpDefaultFonts(): void { + for (const asset of [ + require('dejavu-fonts-ttf/ttf/DejaVuSans.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-Bold.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-Oblique.ttf'), + require('dejavu-fonts-ttf/ttf/DejaVuSans-BoldOblique.ttf'), + require('@expo-google-fonts/noto-color-emoji/400Regular/NotoColorEmoji_400Regular.ttf'), + ]) { + const assetId: unknown = asset; + if (typeof assetId !== 'number') { + throw new Error('Expected a bundled Fantom font asset ID.'); + } + NativeFantom.registerFont( + nullthrows(Image.resolveAssetSource(assetId)).uri, + ); + } +} diff --git a/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js b/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js index cc9aeaadac41..32d8c3e815a9 100644 --- a/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js +++ b/private/react-native-fantom/src/setUpDefaultReactNativeEnvironment.js @@ -9,6 +9,8 @@ */ import setUpDefaultReactNativeEnvironment from '../../../packages/react-native/src/private/setup/setUpDefaultReactNativeEnvironment'; +import setUpFantomEnvironment from './setUpFantomEnvironment'; // Do NOT enable developer tools for Fantom. setUpDefaultReactNativeEnvironment(false); +setUpFantomEnvironment(); diff --git a/private/react-native-fantom/src/setUpFantomEnvironment.js b/private/react-native-fantom/src/setUpFantomEnvironment.js new file mode 100644 index 000000000000..e2193c2f0613 --- /dev/null +++ b/private/react-native-fantom/src/setUpFantomEnvironment.js @@ -0,0 +1,24 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import setUpAssetSourceResolver from './setUpAssetSourceResolver'; +import setUpDefaultFonts from './setUpDefaultFonts'; + +let initialized = false; + +export default function setUpFantomEnvironment(): void { + if (initialized) { + return; + } + + setUpAssetSourceResolver(); + setUpDefaultFonts(); + initialized = true; +} diff --git a/private/react-native-fantom/tester/CMakeLists.txt b/private/react-native-fantom/tester/CMakeLists.txt index af4c741f5ab6..619d86a3e94b 100644 --- a/private/react-native-fantom/tester/CMakeLists.txt +++ b/private/react-native-fantom/tester/CMakeLists.txt @@ -3,7 +3,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -cmake_minimum_required(VERSION 3.13) +cmake_minimum_required(VERSION 3.22) set(CMAKE_VERBOSE_MAKEFILE on) if(UNIX AND NOT APPLE) set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -24,12 +24,12 @@ find_library(LIB_HERMES hermesvm add_library(hermes-engine::hermesvm INTERFACE IMPORTED) set_target_properties(hermes-engine::hermesvm PROPERTIES INTERFACE_LINK_LIBRARIES ${LIB_HERMES}) -find_package(OpenSSL REQUIRED) # Boost in NDK is not compatible with desktop build add_third_party_subdir(boost) # Third-party downloaded targets +add_third_party_subdir(boringssl) add_react_third_party_ndk_subdir(glog) add_react_third_party_ndk_subdir(double-conversion) add_react_third_party_ndk_subdir(fast_float) @@ -37,6 +37,7 @@ add_react_third_party_ndk_subdir(fmt) add_fantom_third_party_subdir(folly) add_fantom_third_party_subdir(gflags) add_fantom_third_party_subdir(nlohmann_json) +add_fantom_third_party_subdir(skia) # Marks everything added below as React Native's own build, to enable private # includes in Fantom. @@ -145,6 +146,7 @@ file(GLOB SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/stubs/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/platform/oss/*.cpp ) +list(APPEND SOURCES ${REACT_COMMON_DIR}/react/renderer/components/textinput/BaseTextInputProps.cpp) add_executable(fantom_tester ${SOURCES}) target_include_directories(fantom_tester PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) @@ -158,6 +160,7 @@ target_link_libraries(fantom_tester folly_runtime gflags glog + fantom_skia # hermes hermes-engine::hermesvm diff --git a/private/react-native-fantom/tester/src/NativeFantom.cpp b/private/react-native-fantom/tester/src/NativeFantom.cpp index 366156b89de7..ab5e2fd30160 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.cpp +++ b/private/react-native-fantom/tester/src/NativeFantom.cpp @@ -22,6 +22,7 @@ #include #include "render/RenderFormatOptions.h" #include "render/RenderOutput.h" +#include "render/TextLayout.h" namespace facebook::react { @@ -114,6 +115,25 @@ std::string NativeFantom::getRenderedOutput( viewTree, formatOptions); } +void NativeFantom::registerAsset( + jsi::Runtime& /*runtime*/, + const std::string& uri, + const std::string& filePath) { + assetPaths_.insert_or_assign(uri, filePath); +} + +void NativeFantom::registerFont( + jsi::Runtime& runtime, + const std::string& filePath) { + try { + const auto asset = assetPaths_.find(filePath); + facebook::react::registerFont( + asset == assetPaths_.end() ? filePath : asset->second); + } catch (const std::exception& error) { + throw jsi::JSError(runtime, error.what()); + } +} + void NativeFantom::reportTestSuiteResultsJSON( jsi::Runtime& /*runtime*/, const std::string& testSuiteResultsJSON) { diff --git a/private/react-native-fantom/tester/src/NativeFantom.h b/private/react-native-fantom/tester/src/NativeFantom.h index 1466020200b9..ce9ce6fae407 100644 --- a/private/react-native-fantom/tester/src/NativeFantom.h +++ b/private/react-native-fantom/tester/src/NativeFantom.h @@ -15,6 +15,7 @@ #include #include #include +#include #include namespace facebook::react { @@ -101,6 +102,9 @@ class NativeFantom : public NativeFantomCxxSpec { SurfaceId surfaceId, NativeFantomGetRenderedOutputRenderFormatOptions options); + void registerAsset(jsi::Runtime &runtime, const std::string &uri, const std::string &filePath); + void registerFont(jsi::Runtime &runtime, const std::string &filePath); + void reportTestSuiteResultsJSON(jsi::Runtime &runtime, const std::string &testSuiteResultsJSON); void enqueueNativeEvent( @@ -149,6 +153,7 @@ class NativeFantom : public NativeFantomCxxSpec { private: TesterAppDelegate &appDelegate_; + std::unordered_map assetPaths_; SurfaceId nextSurfaceId_ = 1; }; diff --git a/private/react-native-fantom/tester/src/TesterMountingManager.cpp b/private/react-native-fantom/tester/src/TesterMountingManager.cpp index 4526eace7d21..ae79e5f1f4a4 100644 --- a/private/react-native-fantom/tester/src/TesterMountingManager.cpp +++ b/private/react-native-fantom/tester/src/TesterMountingManager.cpp @@ -21,6 +21,8 @@ TesterMountingManager::TesterMountingManager( std::function&& onAfterMount) : onAfterMount_(onAfterMount), renderer_(std::make_unique()) { imageLoader_ = std::make_shared(); + textLayoutManager_ = std::make_shared( + std::make_shared()); } void TesterMountingManager::executeMount( @@ -116,7 +118,7 @@ folly::dynamic TesterMountingManager::getViewFabricUpdateProps( } ComponentRegistryFactory TesterMountingManager::getComponentRegistryFactory() { - return getDefaultComponentRegistryFactory(); + return getDefaultComponentRegistryFactory(textLayoutManager_); } StubViewTree TesterMountingManager::getViewTree(SurfaceId surfaceId) { diff --git a/private/react-native-fantom/tester/src/TesterMountingManager.h b/private/react-native-fantom/tester/src/TesterMountingManager.h index cdfdf96d584d..b8a8e506c178 100644 --- a/private/react-native-fantom/tester/src/TesterMountingManager.h +++ b/private/react-native-fantom/tester/src/TesterMountingManager.h @@ -15,6 +15,7 @@ #include #include "render/RenderOutput.h" +#include "render/TextLayout.h" namespace facebook::react { class IImageLoader; @@ -47,6 +48,7 @@ class TesterMountingManager : public IMountingManager { } std::shared_ptr imageLoader_; + std::shared_ptr textLayoutManager_; std::shared_ptr getImageLoader() noexcept override { diff --git a/private/react-native-fantom/tester/src/render/TextLayout.cpp b/private/react-native-fantom/tester/src/render/TextLayout.cpp new file mode 100644 index 000000000000..a065fd528ea6 --- /dev/null +++ b/private/react-native-fantom/tester/src/render/TextLayout.cpp @@ -0,0 +1,586 @@ +/* + * 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. + */ + +#include "TextLayout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef FANTOM_SKIA_FACTORY_PROVIDED +/* static */ sk_sp SkFontMgr::Factory() { + return SkFontMgr_New_Custom_Empty(); +} +#endif + +namespace facebook::react { +namespace { + +constexpr auto defaultFontFamily = "DejaVu Sans"; +constexpr auto emojiFontFamily = "Noto Color Emoji"; + +struct RegisteredFont { + std::string family; + sk_sp face; + std::shared_ptr shapingFont; +}; +struct FontRegistry { + std::vector fonts; + std::mutex mutex; +}; + +FontRegistry& fontRegistry() { + static FontRegistry registry; + return registry; +} + +std::shared_ptr makeShapingFont(SkTypeface* typeface) { + const auto face = std::unique_ptr( + hb_face_create_for_tables( + [](hb_face_t*, hb_tag_t tag, void* data) { + auto table = static_cast(data)->copyTableData(tag); + if (!table) { + return hb_blob_get_empty(); + } + auto* bytes = table.release(); + return hb_blob_create( + static_cast(bytes->data()), + static_cast(bytes->size()), + HB_MEMORY_MODE_READONLY, + bytes, + [](void* owner) { static_cast(owner)->unref(); }); + }, + typeface, + nullptr), + hb_face_destroy); + auto font = + std::shared_ptr(hb_font_create(face.get()), hb_font_destroy); + hb_ot_font_set_funcs(font.get()); + hb_font_make_immutable(font.get()); + return font; +} + +struct ShapedText { + Float width{}; + sk_sp blob; +}; + +ShapedText shapeEmoji(const SkFont& font, const std::string& text) { + if (text.size() > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Fantom text is too long to shape"); + } + std::shared_ptr shapingFont; + { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& registered : registry.fonts) { + if (registered.face.get() == font.getTypeface()) { + shapingFont = registered.shapingFont; + break; + } + } + } + if (!shapingFont) { + throw std::runtime_error("Cannot shape an unregistered Fantom font"); + } + const auto buffer = + std::unique_ptr( + hb_buffer_create(), hb_buffer_destroy); + hb_buffer_set_flags(buffer.get(), HB_BUFFER_FLAG_REMOVE_DEFAULT_IGNORABLES); + hb_buffer_add_utf8( + buffer.get(), text.data(), static_cast(text.size()), 0, -1); + hb_buffer_guess_segment_properties(buffer.get()); + hb_shape(shapingFont.get(), buffer.get(), nullptr, 0); + if (hb_buffer_allocation_successful(buffer.get()) == 0) { + throw std::runtime_error("Cannot allocate Fantom text shaping buffer"); + } + unsigned count = 0; + const auto* glyphInfo = hb_buffer_get_glyph_infos(buffer.get(), &count); + const auto* glyphPositions = + hb_buffer_get_glyph_positions(buffer.get(), nullptr); + if (count == 0) { + return {}; + } + if (glyphInfo == nullptr || glyphPositions == nullptr || + count > static_cast(std::numeric_limits::max())) { + throw std::runtime_error("Cannot read Fantom shaped glyphs"); + } + const auto glyphs = std::span(glyphInfo, count); + const auto positions = std::span(glyphPositions, count); + SkTextBlobBuilder builder; + const auto& run = builder.allocRunPos(font, static_cast(count)); + auto* points = run.points(); + if (run.glyphs == nullptr || points == nullptr) { + throw std::runtime_error("Cannot allocate Fantom shaped glyphs"); + } + const auto outputGlyphs = std::span(run.glyphs, count); + const auto outputPositions = std::span(points, count); + const auto scale = font.getSize() / + static_cast(hb_face_get_upem(hb_font_get_face(shapingFont.get()))); + Float width = 0; + for (unsigned i = 0; i < count; ++i) { + outputGlyphs[i] = static_cast(glyphs[i].codepoint); + outputPositions[i] = { + .fX = width + static_cast(positions[i].x_offset) * scale, + .fY = -static_cast(positions[i].y_offset) * scale}; + width += static_cast(positions[i].x_advance) * scale; + } + return {.width = width, .blob = builder.make()}; +} + +size_t nextTextUnitEnd(const std::string& text, size_t start) { + const auto bytes = std::span(text.data(), text.size()); + const auto* cursor = bytes.subspan(start).data(); + const auto* end = std::to_address(bytes.end()); + const auto codepoint = SkUTF::NextUTF8(&cursor, end); + if (codepoint < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + auto* next = cursor; + auto following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + if (following == 0xfe0e || following == 0xfe0f) { + cursor = next; + following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + } + if (codepoint == '#' || codepoint == '*' || + (codepoint >= '0' && codepoint <= '9')) { + if (following == 0x20e3) { + cursor = next; + } + } + return static_cast(cursor - text.data()); +} + +Float getFontSizeMultiplier(const TextAttributes& attributes) { + if (!attributes.allowFontScaling.value_or(true)) { + return 1; + } + const auto multiplier = std::isfinite(attributes.fontSizeMultiplier) + ? attributes.fontSizeMultiplier + : 1; + return std::isfinite(attributes.maxFontSizeMultiplier) && + attributes.maxFontSizeMultiplier >= 1 + ? std::min(multiplier, attributes.maxFontSizeMultiplier) + : multiplier; +} + +sk_sp typeface(const TextAttributes& attributes) { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + const auto family = + attributes.fontFamily.empty() ? defaultFontFamily : attributes.fontFamily; + const bool bold = attributes.fontWeight.value_or(FontWeight::Regular) >= + FontWeight::Semibold; + const bool italic = attributes.fontStyle == FontStyle::Italic; + sk_sp closest; + int closestDistance = std::numeric_limits::max(); + for (const auto& font : registry.fonts) { + if (font.family != family) { + continue; + } + const auto style = font.face->fontStyle(); + const auto distance = std::abs(style.weight() - (bold ? 700 : 400)) + + ((style.slant() != SkFontStyle::kUpright_Slant) != italic ? 1000 : 0); + if (distance < closestDistance) { + closest = font.face; + closestDistance = distance; + } + } + if (closest) { + return closest; + } + for (const auto& font : registry.fonts) { + if (font.family == defaultFontFamily && + font.face->fontStyle() == SkFontStyle::Normal()) { + return font.face; + } + } + throw std::runtime_error( + "Fantom requires its bundled default font before measuring text"); +} + +sk_sp emojiTypeface() { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& font : registry.fonts) { + if (font.family == emojiFontFamily) { + return font.face; + } + } + return nullptr; +} + +bool hasDefaultEmojiPresentation(SkUnichar codepoint) { + // Unicode 16.0 Emoji_Presentation. + static constexpr auto ranges = std::to_array>({ + {0x231a, 0x231b}, {0x23e9, 0x23ec}, {0x23f0, 0x23f0}, + {0x23f3, 0x23f3}, {0x25fd, 0x25fe}, {0x2614, 0x2615}, + {0x2648, 0x2653}, {0x267f, 0x267f}, {0x2693, 0x2693}, + {0x26a1, 0x26a1}, {0x26aa, 0x26ab}, {0x26bd, 0x26be}, + {0x26c4, 0x26c5}, {0x26ce, 0x26ce}, {0x26d4, 0x26d4}, + {0x26ea, 0x26ea}, {0x26f2, 0x26f3}, {0x26f5, 0x26f5}, + {0x26fa, 0x26fa}, {0x26fd, 0x26fd}, {0x2705, 0x2705}, + {0x270a, 0x270b}, {0x2728, 0x2728}, {0x274c, 0x274c}, + {0x274e, 0x274e}, {0x2753, 0x2755}, {0x2757, 0x2757}, + {0x2795, 0x2797}, {0x27b0, 0x27b0}, {0x27bf, 0x27bf}, + {0x2b1b, 0x2b1c}, {0x2b50, 0x2b50}, {0x2b55, 0x2b55}, + {0x1f004, 0x1f004}, {0x1f0cf, 0x1f0cf}, {0x1f18e, 0x1f18e}, + {0x1f191, 0x1f19a}, {0x1f1e6, 0x1f1ff}, {0x1f201, 0x1f201}, + {0x1f21a, 0x1f21a}, {0x1f22f, 0x1f22f}, {0x1f232, 0x1f236}, + {0x1f238, 0x1f23a}, {0x1f250, 0x1f251}, {0x1f300, 0x1f320}, + {0x1f32d, 0x1f335}, {0x1f337, 0x1f37c}, {0x1f37e, 0x1f393}, + {0x1f3a0, 0x1f3ca}, {0x1f3cf, 0x1f3d3}, {0x1f3e0, 0x1f3f0}, + {0x1f3f4, 0x1f3f4}, {0x1f3f8, 0x1f43e}, {0x1f440, 0x1f440}, + {0x1f442, 0x1f4fc}, {0x1f4ff, 0x1f53d}, {0x1f54b, 0x1f54e}, + {0x1f550, 0x1f567}, {0x1f57a, 0x1f57a}, {0x1f595, 0x1f596}, + {0x1f5a4, 0x1f5a4}, {0x1f5fb, 0x1f64f}, {0x1f680, 0x1f6c5}, + {0x1f6cc, 0x1f6cc}, {0x1f6d0, 0x1f6d2}, {0x1f6d5, 0x1f6d7}, + {0x1f6dc, 0x1f6df}, {0x1f6eb, 0x1f6ec}, {0x1f6f4, 0x1f6fc}, + {0x1f7e0, 0x1f7eb}, {0x1f7f0, 0x1f7f0}, {0x1f90c, 0x1f93a}, + {0x1f93c, 0x1f945}, {0x1f947, 0x1f9ff}, {0x1fa70, 0x1fa7c}, + {0x1fa80, 0x1fa89}, {0x1fa8f, 0x1fac6}, {0x1face, 0x1fadc}, + {0x1fadf, 0x1fae9}, {0x1faf0, 0x1faf8}, + }); + const auto range = std::lower_bound( + ranges.begin(), + ranges.end(), + codepoint, + [](const auto& bounds, SkUnichar value) { return bounds[1] < value; }); + return range != ranges.end() && (*range)[0] <= codepoint; +} + +bool hasColorEmoji(const std::string& text, const sk_sp& emoji) { + if (!emoji) { + return false; + } + const auto* cursor = text.data(); + const auto bytes = std::span(text.data(), text.size()); + const auto* end = std::to_address(bytes.end()); + while (cursor < end) { + const auto codepoint = SkUTF::NextUTF8(&cursor, end); + if (codepoint < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + const auto* next = cursor; + const auto following = next < end ? SkUTF::NextUTF8(&next, end) : -1; + if (following != 0xfe0e && + (following == 0xfe0f || codepoint == 0x20e3 || + hasDefaultEmojiPresentation(codepoint)) && + emoji->unicharToGlyph(codepoint) != 0) { + return true; + } + } + return false; +} + +bool hasGlyphs(const SkFont& font, const std::string& text) { + const auto count = SkUTF::CountUTF8(text.data(), text.size()); + if (count < 0) { + throw std::runtime_error("Invalid UTF-8 in Fantom text"); + } + std::vector glyphs(static_cast(count)); + font.textToGlyphs( + text.data(), text.size(), SkTextEncoding::kUTF8, glyphs.data(), count); + return std::ranges::none_of(glyphs, [](auto glyph) { return glyph == 0; }); +} + +std::string fallbackFamily( + const std::string& character, + const TextAttributes& attributes) { + const auto* cursor = character.data(); + const auto bytes = std::span(character.data(), character.size()); + const auto codepoint = SkUTF::NextUTF8(&cursor, std::to_address(bytes.end())); + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + for (const auto& font : registry.fonts) { + if (font.face->unicharToGlyph(codepoint) != 0) { + return font.family; + } + } + return attributes.fontFamily; +} + +AttributedString resolveFontFallback( + SkCanvas* canvas, + const AttributedString& text) { + AttributedString resolved; + const auto emoji = emojiTypeface(); + for (const auto& fragment : text.getFragments()) { + const auto font = applyFont(canvas, fragment.textAttributes); + if (fragment.isAttachment() || + (hasGlyphs(font, fragment.string) && + !hasColorEmoji(fragment.string, emoji))) { + resolved.appendFragment(AttributedString::Fragment{fragment}); + continue; + } + auto run = fragment; + run.string.clear(); + for (size_t start = 0; start < fragment.string.size();) { + const auto end = nextTextUnitEnd(fragment.string, start); + const auto character = fragment.string.substr(start, end - start); + const auto family = hasColorEmoji(character, emoji) + ? std::string(emojiFontFamily) + : hasGlyphs(font, character) + ? fragment.textAttributes.fontFamily + : fallbackFamily(character, fragment.textAttributes); + if (family != run.textAttributes.fontFamily && !run.string.empty()) { + resolved.appendFragment(AttributedString::Fragment{run}); + run.string.clear(); + } + run.textAttributes.fontFamily = family; + run.string += character; + start = end; + } + if (!run.string.empty()) { + resolved.appendFragment(std::move(run)); + } + } + return resolved; +} + +} // namespace + +void registerFont(const std::string& filePath) { + auto& registry = fontRegistry(); + std::lock_guard lock(registry.mutex); + static const auto manager = SkFontMgr_New_Custom_Empty(); + auto face = manager->makeFromFile(filePath.c_str()); + if (!face) { + throw std::runtime_error("Cannot register Fantom font: " + filePath); + } + SkString family; + face->getFamilyName(&family); + auto shapingFont = std::string_view(family.c_str()) == emojiFontFamily + ? makeShapingFont(face.get()) + : nullptr; + registry.fonts.push_back( + {family.c_str(), std::move(face), std::move(shapingFont)}); +} + +SkFont applyFont(SkCanvas* /*canvas*/, const TextAttributes& attributes) { + SkFont font( + typeface(attributes), + (std::isfinite(attributes.fontSize) ? attributes.fontSize : 14) * + getFontSizeMultiplier(attributes)); + font.setEdging(SkFont::Edging::kAntiAlias); + font.setHinting(SkFontHinting::kNone); + font.setSubpixel(true); + font.setLinearMetrics(true); + font.setEmbeddedBitmaps(true); + return font; +} + +TextLayout layoutText( + SkCanvas* canvas, + const AttributedString& text, + const ParagraphAttributes& paragraph, + Float maximumWidth) { + TextLayout result; + TextLine line; + const auto maxLines = paragraph.maximumNumberOfLines > 0 + ? paragraph.maximumNumberOfLines + : std::numeric_limits::max(); + auto finishLine = [&] { + result.measurement.size.width = + std::max(result.measurement.size.width, line.width); + result.measurement.size.height += line.height; + result.lines.push_back(std::move(line)); + line = {}; + }; + + const auto resolved = resolveFontFallback(canvas, text); + for (const auto& fragment : resolved.getFragments()) { + const auto selectedFont = applyFont(canvas, fragment.textAttributes); + SkFontMetrics font{}; + selectedFont.getMetrics(&font); + const auto height = std::isfinite(fragment.textAttributes.lineHeight) + ? fragment.textAttributes.lineHeight * + getFontSizeMultiplier(fragment.textAttributes) + : static_cast((font.fDescent - font.fAscent + font.fLeading)); + const auto measure = [&](const std::string& token) -> ShapedText { + if (fragment.textAttributes.fontFamily == emojiFontFamily && + SkUTF::CountUTF8(token.data(), token.size()) > 1) { + return shapeEmoji(selectedFont, token); + } + return { + .width = selectedFont.measureText( + token.data(), token.size(), SkTextEncoding::kUTF8), + .blob = nullptr}; + }; + auto append = [&](const std::string& token, ShapedText shaped) { + line.runs.push_back( + {token, + fragment.textAttributes, + line.width, + shaped.width, + static_cast(-font.fAscent), + std::move(shaped.blob)}); + line.width += shaped.width; + line.height = std::max(line.height, height); + line.ascent = std::max(line.ascent, static_cast(-font.fAscent)); + }; + + if (fragment.isAttachment()) { + const auto size = fragment.parentShadowView.layoutMetrics.frame.size; + if (line.width > 0 && line.width + size.width > maximumWidth) { + finishLine(); + } + result.measurement.attachments.push_back( + {.frame = + {.origin = + {.x = line.width, .y = result.measurement.size.height}, + .size = size}, + .isClipped = static_cast(result.lines.size()) >= maxLines}); + line.width += size.width; + line.height = std::max(line.height, size.height); + continue; + } + + // Break at whitespace, then at UTF-8 character boundaries for long words. + for (size_t start = 0; start < fragment.string.size();) { + if (fragment.string[start] == '\n') { + line.height = std::max(line.height, height); + finishLine(); + ++start; + continue; + } + auto end = start + 1; + const bool whitespace = + fragment.string[start] == ' ' || fragment.string[start] == '\t'; + while (end < fragment.string.size() && fragment.string[end] != '\n' && + (fragment.string[end] == ' ' || fragment.string[end] == '\t') == + whitespace) { + ++end; + } + auto token = fragment.string.substr(start, end - start); + auto shaped = measure(token); + if (line.width > 0 && line.width + shaped.width > maximumWidth) { + finishLine(); + if (whitespace) { + start = end; + continue; + } + } + if (shaped.width <= maximumWidth || whitespace) { + append(token, std::move(shaped)); + } else { + for (size_t i = 0; i < token.size();) { + const auto next = nextTextUnitEnd(token, i); + auto character = token.substr(i, next - i); + shaped = measure(character); + if (line.width > 0 && line.width + shaped.width > maximumWidth) { + finishLine(); + } + append(character, std::move(shaped)); + i = next; + } + } + start = end; + } + } + if (!line.runs.empty() || line.height > 0) { + finishLine(); + } + if (static_cast(result.lines.size()) > maxLines) { + result.lines.resize(maxLines); + result.measurement.size = {}; + for (const auto& visibleLine : result.lines) { + result.measurement.size.width = + std::max(result.measurement.size.width, visibleLine.width); + result.measurement.size.height += visibleLine.height; + } + } + return result; +} + +void paintText(SkCanvas* canvas, const TextLayout& layout, Float width) { + if (canvas == nullptr) { + throw std::invalid_argument("Fantom requires a canvas to paint text"); + } + Float y = 0; + for (const auto& line : layout.lines) { + Float offset = 0; + if (!line.runs.empty()) { + const auto alignment = line.runs.front().attributes.alignment; + if (alignment == TextAlignment::Center) { + offset = (width - line.width) / 2; + } else if (alignment == TextAlignment::Right) { + offset = width - line.width; + } + } + for (const auto& run : line.runs) { + const auto font = applyFont(canvas, run.attributes); + const auto opacity = + std::isfinite(run.attributes.opacity) ? run.attributes.opacity : 1; + const auto paintFor = [&](SharedColor color) { + SkPaint paint; + paint.setAntiAlias(true); + paint.setColor(SkColorSetARGB( + alphaFromColor(color), + redFromColor(color), + greenFromColor(color), + blueFromColor(color))); + paint.setAlphaf(paint.getAlphaf() * opacity); + return paint; + }; + if (run.attributes.backgroundColor) { + canvas->drawRect( + SkRect::MakeXYWH(offset + run.x, y, run.width, line.height), + paintFor(run.attributes.backgroundColor)); + } + const auto color = run.attributes.foregroundColor + ? run.attributes.foregroundColor + : blackColor(); + const auto baseline = y + (line.height - line.ascent) / 2 + line.ascent; + if (run.blob) { + canvas->drawTextBlob( + run.blob, offset + run.x, baseline, paintFor(color)); + } else { + canvas->drawSimpleText( + run.text.data(), + run.text.size(), + SkTextEncoding::kUTF8, + offset + run.x, + baseline, + font, + paintFor(color)); + } + } + y += line.height; + } +} + +TextMeasurement FantomTextLayoutManager::measure( + const AttributedStringBox& text, + const ParagraphAttributes& paragraph, + const TextLayoutContext& /*context*/, + const LayoutConstraints& constraints) const { + auto result = + layoutText( + nullptr, text.getValue(), paragraph, constraints.maximumSize.width) + .measurement; + result.size = constraints.clamp(result.size); + return result; +} + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/render/TextLayout.h b/private/react-native-fantom/tester/src/render/TextLayout.h new file mode 100644 index 000000000000..31ee898572cc --- /dev/null +++ b/private/react-native-fantom/tester/src/render/TextLayout.h @@ -0,0 +1,59 @@ +/* + * 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 +#include +#include + +#include +#include +#include + +namespace facebook::react { + +struct TextRun { + std::string text; + TextAttributes attributes; + Float x{}; + Float width{}; + Float ascent{}; + sk_sp blob; +}; + +struct TextLine { + std::vector runs; + Float width{}; + Float height{}; + Float ascent{}; +}; + +struct TextLayout { + std::vector lines; + TextMeasurement measurement; +}; + +using TextCanvas = SkCanvas; +SkFont applyFont(TextCanvas *canvas, const TextAttributes &attributes); + +void registerFont(const std::string &filePath); +TextLayout +layoutText(TextCanvas *canvas, const AttributedString &text, const ParagraphAttributes ¶graph, Float maximumWidth); +void paintText(TextCanvas *canvas, const TextLayout &layout, Float width); + +class FantomTextLayoutManager : public TextLayoutManager { + public: + using TextLayoutManager::TextLayoutManager; + TextMeasurement measure( + const AttributedStringBox &text, + const ParagraphAttributes ¶graph, + const TextLayoutContext &context, + const LayoutConstraints &constraints) const override; +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/stubs/FantomTextInput.h b/private/react-native-fantom/tester/src/stubs/FantomTextInput.h new file mode 100644 index 000000000000..33620e4dbc26 --- /dev/null +++ b/private/react-native-fantom/tester/src/stubs/FantomTextInput.h @@ -0,0 +1,88 @@ +/* + * 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 +#include +#include +#include +#include "../render/TextLayout.h" + +namespace facebook::react { + +class FantomTextInputProps : public BaseTextInputProps { + public: + FantomTextInputProps() = default; + FantomTextInputProps(const PropsParserContext &context, const FantomTextInputProps &source, const RawProps &raw) + : BaseTextInputProps(context, source, raw), + secureTextEntry(convertRawProp(context, raw, "secureTextEntry", source.secureTextEntry, false)) + { + } + bool secureTextEntry{}; +}; + +// NOLINTNEXTLINE(facebook-hte-CArray, modernize-avoid-c-arrays) +inline constexpr char FantomAndroidTextInputName[] = "AndroidTextInput"; +// NOLINTNEXTLINE(facebook-hte-CArray, modernize-avoid-c-arrays) +inline constexpr char FantomSinglelineTextInputName[] = "RCTSinglelineTextInputView"; +// NOLINTNEXTLINE(facebook-hte-CArray, modernize-avoid-c-arrays) +inline constexpr char FantomMultilineTextInputName[] = "RCTMultilineTextInputView"; + +template +class FantomTextInputShadowNode : public ConcreteViewShadowNode { + public: + using Base = ConcreteViewShadowNode; + using Base::Base; + static ShadowNodeTraits BaseTraits() + { + auto traits = Base::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + std::shared_ptr textLayoutManager; + + protected: + Size measureContent(const LayoutContext &context, const LayoutConstraints &constraints) const override + { + const auto &props = this->getConcreteProps(); + AttributedString text; + text.appendFragment( + {.string = props.text.empty() ? props.placeholder : props.text, + .textAttributes = props.getEffectiveTextAttributes(context.fontSizeMultiplier), + .parentShadowView = {}}); + auto paragraph = props.paragraphAttributes; + if (!props.multiline) { + paragraph.maximumNumberOfLines = 1; + } + return textLayoutManager + ->measure( + AttributedStringBox{text}, + paragraph, + {.pointScaleFactor = context.pointScaleFactor, .surfaceId = this->getSurfaceId()}, + constraints) + .size; + } +}; + +template +class FantomTextInputComponentDescriptor : public ConcreteComponentDescriptor> { + public: + using Base = ConcreteComponentDescriptor>; + using Base::Base; + + protected: + void adopt(ShadowNode &node) const override + { + Base::adopt(node); + static_cast &>(node).textLayoutManager = + this->contextContainer_->template at>("TextLayoutManager"); + } +}; + +} // namespace facebook::react diff --git a/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h b/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h index b68623060fa9..b273736c5293 100644 --- a/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h +++ b/private/react-native-fantom/tester/src/stubs/StubComponentRegistryFactory.h @@ -7,6 +7,8 @@ #pragma once +#include "FantomTextInput.h" + #include #include #include @@ -20,11 +22,13 @@ namespace facebook::react { -inline ComponentRegistryFactory getDefaultComponentRegistryFactory() +inline ComponentRegistryFactory getDefaultComponentRegistryFactory(std::shared_ptr textLayoutManager) { - return [](const EventDispatcher::Weak &eventDispatcher, - const std::shared_ptr &contextContainer) { - static auto providerRegistry = []() { + return [textLayoutManager = std::move(textLayoutManager)]( + const EventDispatcher::Weak &eventDispatcher, + const std::shared_ptr &contextContainer) { + contextContainer->insert("TextLayoutManager", textLayoutManager); + static const auto providerRegistry = []() { auto providerRegistry = std::make_shared(); providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); @@ -34,9 +38,16 @@ inline ComponentRegistryFactory getDefaultComponentRegistryFactory() providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); providerRegistry->add(concreteComponentDescriptorProvider()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); + providerRegistry->add( + concreteComponentDescriptorProvider>()); return providerRegistry; }(); - return providerRegistry->createComponentDescriptorRegistry({eventDispatcher, contextContainer, nullptr}); + return providerRegistry->createComponentDescriptorRegistry( + {.eventDispatcher = eventDispatcher, .contextContainer = contextContainer, .flavor = nullptr}); }; } diff --git a/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt b/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt new file mode 100644 index 000000000000..07334cf4fa00 --- /dev/null +++ b/private/react-native-fantom/tester/third-party/boringssl/CMakeLists.txt @@ -0,0 +1,13 @@ +# 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. + +set(BUILD_TESTING OFF) +set(BUILD_SHARED_LIBS OFF) +add_subdirectory( + "${FANTOM_THIRD_PARTY_DIR}/boringssl" + "${CMAKE_CURRENT_BINARY_DIR}/boringssl" + EXCLUDE_FROM_ALL +) +add_library(OpenSSL::Crypto ALIAS crypto) diff --git a/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt b/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt new file mode 100644 index 000000000000..1dbd1b741305 --- /dev/null +++ b/private/react-native-fantom/tester/third-party/skia/CMakeLists.txt @@ -0,0 +1,66 @@ +# 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. + +include(ExternalProject) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +find_package(Threads REQUIRED) + +set(SKIA_SOURCE_DIR "${FANTOM_THIRD_PARTY_DIR}/skia") +set(SKIA_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/out") +set(SKIA_RASTER_PIPELINE_HEADER "${SKIA_SOURCE_DIR}/src/opts/SkRasterPipeline_opts.h") +file(READ "${SKIA_RASTER_PIPELINE_HEADER}" SKIA_RASTER_PIPELINE_SOURCE) +set(SKIA_SCALAR_SELECTION "#if defined(SK_RASTER_PIPELINE_FORCE_SCALAR) || !defined(__clang__)\n #define JUMPER_IS_SCALAR") +string(FIND "${SKIA_RASTER_PIPELINE_SOURCE}" "${SKIA_SCALAR_SELECTION}" SKIA_SCALAR_SELECTION_INDEX) +if(SKIA_SCALAR_SELECTION_INDEX EQUAL -1) + message(FATAL_ERROR "Fantom requires Skia's scalar raster pipeline patch from prepareSkiaSource") +endif() +set(BUILD_SHARED_LIBS OFF) +set(HB_BUILD_SUBSET OFF CACHE BOOL "Build only the HarfBuzz shaping library") +set(HB_HAVE_CORETEXT OFF CACHE BOOL "Use OpenType shaping on every platform") +add_subdirectory("${SKIA_SOURCE_DIR}/third_party/externals/harfbuzz" harfbuzz) +if(NOT FANTOM_NINJA) + find_program(FANTOM_NINJA ninja REQUIRED) +endif() + +set(SKIA_GN_ARGS + "is_official_build=true is_debug=false is_component_build=false" + "skia_enable_gpu=false skia_enable_graphite=false skia_enable_pdf=false" + "skia_enable_tools=false skia_enable_skottie=false skia_enable_svg=false" + "skia_use_fontconfig=false skia_use_fonthost_mac=false skia_use_freetype=true" + "skia_enable_fontmgr_custom_directory=false skia_enable_fontmgr_custom_embedded=false" + "skia_enable_fontmgr_custom_empty=true skia_enable_fontmgr_win=false" + "skia_enable_fontmgr_win_gdi=false skia_use_harfbuzz=false skia_use_icu=false" + "skia_use_fixed_gamma_text=false" + "extra_cflags=[\"-DSK_GAMMA_EXPONENT=1.0\",\"-DSK_GAMMA_CONTRAST=0.0\",\"-DSK_DISABLE_LEGACY_SHADERCONTEXT\",\"-DSK_DISABLE_LOWP_RASTER_PIPELINE\",\"-DSK_FORCE_RASTER_PIPELINE_BLITTER\",\"-DSK_RASTER_PIPELINE_FORCE_SCALAR\",\"-ffp-contract=off\"]" + "skia_use_expat=false skia_use_piex=false skia_use_dng_sdk=false" + "skia_use_libjpeg_turbo_decode=false skia_use_libjpeg_turbo_encode=false" + "skia_use_libwebp_decode=false skia_use_libwebp_encode=false skia_use_libgifcodec=false" + "skia_use_system_freetype2=false skia_use_system_libpng=false skia_use_system_zlib=false" + "cc=\"${CMAKE_C_COMPILER}\" cxx=\"${CMAKE_CXX_COMPILER}\"" +) +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + list(APPEND SKIA_GN_ARGS "is_clang=true") +else() + list(APPEND SKIA_GN_ARGS "is_clang=false") +endif() +string(JOIN " " SKIA_GN_ARGS ${SKIA_GN_ARGS}) + +ExternalProject_Add(fantom_skia_build + SOURCE_DIR "${SKIA_SOURCE_DIR}" + DOWNLOAD_COMMAND "${Python3_EXECUTABLE}" "${SKIA_SOURCE_DIR}/bin/fetch-gn" + CONFIGURE_COMMAND "${SKIA_SOURCE_DIR}/bin/gn" gen "${SKIA_BUILD_DIR}" "--root=${SKIA_SOURCE_DIR}" "--args=${SKIA_GN_ARGS}" + BUILD_COMMAND "${FANTOM_NINJA}" -C "${SKIA_BUILD_DIR}" skia + INSTALL_COMMAND "" + BUILD_BYPRODUCTS "${SKIA_BUILD_DIR}/libskia.a" +) +target_include_directories(harfbuzz INTERFACE "${SKIA_SOURCE_DIR}/third_party/externals/harfbuzz/src") +add_library(fantom_skia STATIC IMPORTED GLOBAL) +set_target_properties(fantom_skia PROPERTIES + IMPORTED_LOCATION "${SKIA_BUILD_DIR}/libskia.a" + INTERFACE_INCLUDE_DIRECTORIES "${SKIA_SOURCE_DIR}" + INTERFACE_COMPILE_DEFINITIONS FANTOM_SKIA_FACTORY_PROVIDED + INTERFACE_LINK_LIBRARIES "Threads::Threads;harfbuzz" +) +add_dependencies(fantom_skia fantom_skia_build) diff --git a/yarn.lock b/yarn.lock index 31472a5db748..bb685c1393c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,6 +1242,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== +"@expo-google-fonts/noto-color-emoji@0.4.7": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@expo-google-fonts/noto-color-emoji/-/noto-color-emoji-0.4.7.tgz#9d3fe605309fb5cd554b145c870c12b38dbde772" + integrity sha512-0jZUhh9r92VouxJ5z59Vxw1BGjbaat7EEEasmsLI+doe5hXS1/d4oZUyfyv/TFvkwuZjqvbDh6WdYM9q0ZhiyA== + "@expo/spawn-async@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" @@ -3786,6 +3791,11 @@ define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +dejavu-fonts-ttf@2.37.3: + version "2.37.3" + resolved "https://registry.yarnpkg.com/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz#23f2bf5af594ca2a8a3a53224224b03467a6e0b8" + integrity sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ== + depd@2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"