diff --git a/.github/workflows/build-npm.yml b/.github/workflows/build-npm.yml index 2eee3f4dde..6dc3d2a8b8 100644 --- a/.github/workflows/build-npm.yml +++ b/.github/workflows/build-npm.yml @@ -19,14 +19,37 @@ jobs: with: submodules: recursive + # The next channel ships the Graphite build; main ships the default (Ganesh) + # build. Both branches are identical — the graphite setup bakes the Dawn and + # Graphite headers into cpp/ and creates the libs/.graphite marker (shipped + # via the files field) that the podspec and build.gradle key off at build time. - name: Setup uses: ./.github/actions/setup with: github_token: ${{ secrets.GITHUB_TOKEN }} + graphite: ${{ github.ref_name == 'next' }} - name: Build package and documentation run: yarn build + # Swap the prebuilt binary packages for their Graphite variants, pinned in + # graphiteDependencies (package.json). Only done at release time so that + # main and next stay identical. + - name: Swap in Graphite binary packages (next channel only) + if: github.ref_name == 'next' + working-directory: packages/skia + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + for (const name of Object.keys(pkg.dependencies)) { + if (name.startsWith('react-native-skia-')) delete pkg.dependencies[name]; + } + Object.assign(pkg.dependencies, pkg.graphiteDependencies); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + node -p "JSON.stringify(require('./package.json').dependencies, null, 2)" + - name: Build NPM Package working-directory: packages/skia run: | diff --git a/apps/docs/docs/text/text.md b/apps/docs/docs/text/text.md index d7a9fc143d..9cefdc88fb 100644 --- a/apps/docs/docs/text/text.md +++ b/apps/docs/docs/text/text.md @@ -132,8 +132,26 @@ The `fontStyle` object can have the following list of optional attributes: - `fontFamily`: The name of the font family. - `fontSize`: The size of the font. -- `fontStyle`: The slant of the font. Can be `normal`, `italic`, or `oblique`. -- `fontWeight`: The weight of the font. Can be `normal`, `bold`, or any of `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900`. +- `fontStyle`: The slant of the font. Can be `normal`, `italic`, or `oblique`, or a `FontSlant` enum value. +- `fontWeight`: The weight of the font. Can be `normal`, `bold`, or any of `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900`, or a `FontWeight` enum value. + +The font style type is exported as `RNFontStyle` (with its `fontStyle` and `fontWeight` attributes typed as `RNFontSlant` and `RNFontWeight`), so you can type your own helpers. +`fontWeight` and `fontStyle` also accept the `FontWeight` and `FontSlant` enums used by the [Paragraph API](/docs/text/paragraph/), meaning the same style values can be shared between `matchFont` and a Paragraph `TextStyle` without any conversion: + +```tsx twoslash +import {matchFont, FontWeight, FontSlant} from "@shopify/react-native-skia"; +import type {RNFontStyle} from "@shopify/react-native-skia"; + +const labelStyle: Partial = { + fontFamily: "Roboto", + fontSize: 16, + // FontWeight.Medium (500) and FontSlant.Italic are the same values + // you would use in a Paragraph TextStyle + fontWeight: FontWeight.Medium, + fontStyle: FontSlant.Italic, +}; +const font = matchFont(labelStyle); +``` By default, `matchFont` uses the system font manager to match the font style. However, if you want to use your custom font manager, you can pass it as the second parameter to the `matchFont` function: diff --git a/packages/skia/android/build.gradle b/packages/skia/android/build.gradle index ae3a4be040..f1eb21a5d6 100644 --- a/packages/skia/android/build.gradle +++ b/packages/skia/android/build.gradle @@ -74,12 +74,18 @@ static def resolveNodePackage(packageName, baseDir) { return proc.text.trim() } -// Graphite is detected via a marker file created by install-skia-graphite, which -// downloads its binaries directly into libs/. For the default (Ganesh) build the -// binaries live in the react-native-skia-android npm package and are read in place. +// Graphite is detected via a marker file (created by install-skia-graphite for +// in-repo development, or shipped in the npm tarball for next-channel releases). +// The prebuilt binaries live in the react-native-skia-android npm package for the +// default (Ganesh) build and in react-native-skia-graphite-android for Graphite. +// In-repo Graphite development downloads them directly into libs/android instead, +// which takes precedence over the npm package when present. def useGraphite = file("${projectDir}/../libs/.graphite").exists() +def localGraphiteLibs = file("${projectDir}/../libs/android") def skiaLibsPath = useGraphite - ? "${projectDir}/../libs/android" + ? (localGraphiteLibs.exists() + ? "${projectDir}/../libs/android" + : "${resolveNodePackage('react-native-skia-graphite-android', projectDir)}/libs") : "${resolveNodePackage('react-native-skia-android', projectDir)}/libs" logger.warn("react-native-skia: SK_GRAPHITE: ${useGraphite}") diff --git a/packages/skia/android/cpp/rnskia-android/OpenGLContext.h b/packages/skia/android/cpp/rnskia-android/OpenGLContext.h index 6a5e2e73ac..7b4fee123b 100644 --- a/packages/skia/android/cpp/rnskia-android/OpenGLContext.h +++ b/packages/skia/android/cpp/rnskia-android/OpenGLContext.h @@ -42,6 +42,11 @@ class OpenGLSharedContext { _glConfig = _glDisplay->chooseConfig(); _glContext = _glDisplay->makeContext(_glConfig, nullptr); _glSurface = _glDisplay->makePixelBufferSurface(_glConfig, 1, 1); + if (_glContext == nullptr || _glSurface == nullptr) { + RNSkLogger::logToConsole( + "Couldn't create the shared OpenGL context or surface"); + return; + } _glContext->makeCurrent(_glSurface.get()); } }; @@ -60,6 +65,10 @@ class OpenGLContext { sk_sp MakeOffscreen(int width, int height, bool useP3ColorSpace = false) { + if (_directContext == nullptr) { + return nullptr; + } + auto colorType = kRGBA_8888_SkColorType; SkSurfaceProps props(0, kUnknown_SkPixelGeometry); @@ -110,6 +119,9 @@ class OpenGLContext { sk_sp MakeImageFromBuffer(void *buffer, bool requireKnownFormat = false) { #if __ANDROID_API__ >= 26 + if (_directContext == nullptr) { + return nullptr; + } const AHardwareBuffer *hardwareBuffer = static_cast(buffer); DeleteImageProc deleteImageProc = nullptr; @@ -181,6 +193,11 @@ class OpenGLContext { // TODO: remove width, height std::unique_ptr MakeWindow(ANativeWindow *window, bool highBitDepth = false) { + if (_directContext == nullptr) { + RNSkLogger::logToConsole( + "The OpenGL context is invalid, the surface will not be rendered"); + return nullptr; + } auto display = OpenGLSharedContext::getInstance().getDisplay(); if (highBitDepth) { // A 10-bit window surface would require the shared EGL context to be @@ -196,7 +213,11 @@ class OpenGLContext { } GrDirectContext *getDirectContext() { return _directContext.get(); } - void makeCurrent() { _glContext->makeCurrent(_glSurface.get()); } + void makeCurrent() { + if (_glContext != nullptr) { + _glContext->makeCurrent(_glSurface.get()); + } + } private: std::unique_ptr _glContext; @@ -209,12 +230,17 @@ class OpenGLContext { auto glConfig = OpenGLSharedContext::getInstance().getConfig(); _glContext = display->makeContext(glConfig, sharedContext); _glSurface = display->makePixelBufferSurface(glConfig, 1, 1); - _glContext->makeCurrent(_glSurface.get()); + if (_glContext == nullptr || _glSurface == nullptr || + !_glContext->makeCurrent(_glSurface.get())) { + RNSkLogger::logToConsole( + "Couldn't create the OpenGL context, Skia rendering is disabled"); + return; + } auto backendInterface = GrGLMakeNativeInterface(); _directContext = GrDirectContexts::MakeGL(backendInterface); if (_directContext == nullptr) { - throw std::runtime_error("GrDirectContexts::MakeGL failed"); + RNSkLogger::logToConsole("GrDirectContexts::MakeGL failed"); } } }; diff --git a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp index 3916c0d67a..da2e02c0ca 100644 --- a/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp +++ b/packages/skia/android/cpp/rnskia-android/OpenGLWindowContext.cpp @@ -16,7 +16,12 @@ namespace RNSkia { sk_sp OpenGLWindowContext::getSurface() { if (_skSurface == nullptr) { - _glContext->makeCurrent(_glSurface.get()); + if (_glSurface == nullptr || !_glContext->makeCurrent(_glSurface.get())) { + RNSkLogger::logToConsole( + "Couldn't create the EGL window surface, the surface will not be " + "rendered"); + return nullptr; + } GLint stencil; glGetIntegerv(GL_STENCIL_BITS, &stencil); @@ -52,7 +57,9 @@ sk_sp OpenGLWindowContext::getSurface() { } void OpenGLWindowContext::present() { - _glContext->makeCurrent(_glSurface.get()); + if (_glSurface == nullptr || !_glContext->makeCurrent(_glSurface.get())) { + return; + } _directContext->flushAndSubmit(); _glSurface->present(); } diff --git a/packages/skia/android/cpp/rnskia-android/gl/Context.h b/packages/skia/android/cpp/rnskia-android/gl/Context.h index f0cc7f8d8f..faa1f69f24 100644 --- a/packages/skia/android/cpp/rnskia-android/gl/Context.h +++ b/packages/skia/android/cpp/rnskia-android/gl/Context.h @@ -26,6 +26,9 @@ class Context { if (_context == EGL_NO_CONTEXT) { return false; } + if (surface == nullptr || !surface->isValid()) { + return false; + } const auto result = eglMakeCurrentIfNecessary(_display, surface->getHandle(), surface->getHandle(), _context) == EGL_TRUE; diff --git a/packages/skia/android/cpp/rnskia-android/gl/Surface.h b/packages/skia/android/cpp/rnskia-android/gl/Surface.h index 74ece55814..333de0fcf1 100644 --- a/packages/skia/android/cpp/rnskia-android/gl/Surface.h +++ b/packages/skia/android/cpp/rnskia-android/gl/Surface.h @@ -17,7 +17,7 @@ class Surface { } } - bool isValid() { return _surface != EGL_NO_SURFACE; } + bool isValid() const { return _surface != EGL_NO_SURFACE; } const EGLSurface &getHandle() const { return _surface; } diff --git a/packages/skia/cpp/api/JsiSkiaContext.h b/packages/skia/cpp/api/JsiSkiaContext.h index 7bf494f812..2b547c9c49 100644 --- a/packages/skia/cpp/api/JsiSkiaContext.h +++ b/packages/skia/cpp/api/JsiSkiaContext.h @@ -79,6 +79,10 @@ class JsiSkiaContext : public JsiSkWrappingSharedPtrHostObject { } auto result = context->makeContextFromNativeSurface(surface, width, height); + if (result == nullptr) { + throw std::runtime_error( + "Couldn't create a Skia context from the native surface"); + } // Return the newly constructed object auto hostObjectInstance = std::make_shared(context, std::move(result)); diff --git a/packages/skia/cpp/rnskia/RNSkView.h b/packages/skia/cpp/rnskia/RNSkView.h index ba92d75f1d..1b96dced7b 100644 --- a/packages/skia/cpp/rnskia/RNSkView.h +++ b/packages/skia/cpp/rnskia/RNSkView.h @@ -84,6 +84,9 @@ class RNSkOffscreenCanvasProvider : public RNSkCanvasProvider { Returns a snapshot of the current surface/canvas */ sk_sp makeSnapshot(SkRect *bounds) { + if (_surface == nullptr) { + return nullptr; + } sk_sp image; if (bounds != nullptr) { SkIRect b = @@ -120,6 +123,9 @@ class RNSkOffscreenCanvasProvider : public RNSkCanvasProvider { Render to a canvas */ bool renderToCanvas(const std::function &cb) override { + if (_surface == nullptr) { + return false; + } cb(_surface->getCanvas()); return true; }; diff --git a/packages/skia/package.json b/packages/skia/package.json index da7ae20146..b48b145ded 100644 --- a/packages/skia/package.json +++ b/packages/skia/package.json @@ -32,7 +32,8 @@ "cpp/**/*.{h,cpp}", "apple/**", "react-native-skia.podspec", - "dist/**" + "dist/**", + "libs/.graphite" ], "scripts": { "lint": "eslint . --ext .ts,.tsx --max-warnings 0 --cache --fix", @@ -140,6 +141,11 @@ "react-native-skia-apple-tvos": "150.0.0", "react-reconciler": "0.31.0" }, + "graphiteDependencies": { + "react-native-skia-graphite-android": "150.0.0", + "react-native-skia-graphite-apple-ios": "150.0.0", + "react-native-skia-graphite-apple-macos": "150.0.0" + }, "eslintIgnore": [ "node_modules/", "lib/" diff --git a/packages/skia/react-native-skia.podspec b/packages/skia/react-native-skia.podspec index 0c71aa3ab6..451c8b957e 100644 --- a/packages/skia/react-native-skia.podspec +++ b/packages/skia/react-native-skia.podspec @@ -29,10 +29,8 @@ end # re-copied and CocoaPods picks up the change. This is best-effort: if `pod install` # does not detect the change, a clean reinstall fixes it (acceptable until the upcoming # Swift Package Manager migration). -install_apple_skia_libs = lambda do |base_dir| - { 'ios' => 'react-native-skia-apple-ios', - 'macos' => 'react-native-skia-apple-macos', - 'tvos' => 'react-native-skia-apple-tvos' }.each do |platform, pkg_name| +install_apple_skia_libs = lambda do |base_dir, packages| + packages.each do |platform, pkg_name| pkg_dir = resolve_node_package.call(pkg_name, base_dir) next if pkg_dir.nil? @@ -54,9 +52,18 @@ install_apple_skia_libs = lambda do |base_dir| end end -# Graphite downloads its binaries directly into libs/; only the default build needs -# the npm packages copied in. -install_apple_skia_libs.call(__dir__) unless use_graphite +# The default (Ganesh) build ships its binaries in the react-native-skia-apple-* +# npm packages, the Graphite build in react-native-skia-graphite-apple-* (no tvOS). +# During in-repo development install-skia-graphite downloads the binaries directly +# into libs/ and the graphite packages are absent from node_modules, in which case +# the copy below is a no-op and the downloaded binaries are used as-is. +apple_skia_packages = use_graphite ? + { 'ios' => 'react-native-skia-graphite-apple-ios', + 'macos' => 'react-native-skia-graphite-apple-macos' } : + { 'ios' => 'react-native-skia-apple-ios', + 'macos' => 'react-native-skia-apple-macos', + 'tvos' => 'react-native-skia-apple-tvos' } +install_apple_skia_libs.call(__dir__, apple_skia_packages) # Set preprocessor definitions based on GRAPHITE flag preprocessor_defs = use_graphite ? @@ -71,14 +78,15 @@ framework_names = ['libskia', 'libsvg', 'libskshaper', 'libskparagraph', # Add Dawn library for Graphite builds (contains dawn::native symbols) framework_names += ['libdawn_combined'] if use_graphite -# Verify that the prebuilt binaries are available (copied in above, or downloaded by -# install-skia-graphite for Graphite builds). +# Verify that the prebuilt binaries are available (copied in above from the npm +# packages, or downloaded by install-skia-graphite for in-repo Graphite builds). unless Dir.exist?(File.join(__dir__, 'libs', 'ios')) && Dir.exist?(File.join(__dir__, 'libs', 'macos')) + expected_packages = apple_skia_packages.values.join(', ') Pod::UI.warn "#{'-' * 72}" Pod::UI.warn "react-native-skia: Skia prebuilt binaries not found in libs/!" Pod::UI.warn "" Pod::UI.warn "Make sure dependencies are installed (yarn install / npm install) so that" - Pod::UI.warn "the react-native-skia-apple-* packages are present, then run `pod install` again." + Pod::UI.warn "the #{expected_packages} packages are present, then run `pod install` again." Pod::UI.warn "#{'-' * 72}" raise "react-native-skia: Skia prebuilt binaries not found. Run `yarn install` then `pod install` to fix this." end diff --git a/packages/skia/src/Platform/Platform.ts b/packages/skia/src/Platform/Platform.ts index 7174d614dc..9815715346 100644 --- a/packages/skia/src/Platform/Platform.ts +++ b/packages/skia/src/Platform/Platform.ts @@ -7,7 +7,7 @@ import { } from "react-native"; import type { DataModule } from "../skia/types"; -import { isRNModule } from "../skia/types"; +import { isRNModule, unwrapModule } from "../skia/types"; import type { IPlatform } from "./IPlatform"; @@ -15,12 +15,11 @@ export const Platform: IPlatform = { OS: RNPlatform.OS, PixelRatio: PixelRatio.get(), resolveAsset: (source: DataModule) => { - // eslint-disable-next-line no-nested-ternary - return isRNModule(source) - ? Image.resolveAssetSource(source).uri - : "uri" in source - ? source.uri - : source.default; + const asset = unwrapModule(source); + if (typeof asset === "string") { + return asset; + } + return isRNModule(asset) ? Image.resolveAssetSource(asset).uri : asset.uri; }, findNodeHandle, View, diff --git a/packages/skia/src/Platform/Platform.web.tsx b/packages/skia/src/Platform/Platform.web.tsx index 1f97faeb28..a4df35eb89 100644 --- a/packages/skia/src/Platform/Platform.web.tsx +++ b/packages/skia/src/Platform/Platform.web.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from "react"; import type { ViewComponent, ViewProps } from "react-native"; import type { DataModule } from "../skia/types"; -import { isRNModule } from "../skia/types"; +import { isRNModule, unwrapModule } from "../skia/types"; import type { IPlatform } from "./IPlatform"; @@ -40,12 +40,16 @@ export const Platform: IPlatform = { OS: "web", PixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1, // window is not defined on node resolveAsset: (source: DataModule) => { - if (isRNModule(source)) { - if (typeof source === "number" && typeof require === "function") { + const asset = unwrapModule(source); + if (typeof asset === "string") { + return asset; + } + if (isRNModule(asset)) { + if (typeof require === "function") { const { getAssetByID, } = require("react-native/Libraries/Image/AssetRegistry"); - const { httpServerLocation, name, type } = getAssetByID(source); + const { httpServerLocation, name, type } = getAssetByID(asset); const uri = `${httpServerLocation}/${name}.${type}`; return uri; } @@ -53,10 +57,7 @@ export const Platform: IPlatform = { "Asset source is a number - this is not supported on the web" ); } - if ("uri" in source) { - return source.uri; - } - return source.default; + return asset.uri; }, findNodeHandle: () => { throw new Error("findNodeHandle is not supported on the web"); diff --git a/packages/skia/src/Platform/__tests__/Platform.spec.ts b/packages/skia/src/Platform/__tests__/Platform.spec.ts new file mode 100644 index 0000000000..a3ad9e0119 --- /dev/null +++ b/packages/skia/src/Platform/__tests__/Platform.spec.ts @@ -0,0 +1,83 @@ +import type { DataModule } from "../../skia/types"; +import { Platform as NativePlatform } from "../Platform"; +import { Platform as WebPlatform } from "../Platform.web"; + +jest.mock("react-native", () => ({ + PixelRatio: { + get(): number { + return 1; + }, + }, + Platform: { OS: "ios" }, + Image: { + resolveAssetSource: (source: number) => ({ + uri: `asset://${source}`, + }), + }, +})); + +jest.mock("react-native/Libraries/Image/AssetRegistry", () => ({ + getAssetByID: (id: number) => ({ + httpServerLocation: "/assets", + name: `font-${id}`, + type: "ttf", + }), +})); + +const metroAsset = { + uri: "https://localhost/assets/font.ttf", + width: 0, + height: 0, +}; + +// Since Expo SDK 52, on web, require() may return an ES module interop +// object ({ default: }) instead of the asset itself (see #2784). +describe("Platform.resolveAsset", () => { + describe("web", () => { + it("resolves a Metro asset", () => { + expect(WebPlatform.resolveAsset(metroAsset)).toBe(metroAsset.uri); + }); + it("resolves a module id via the asset registry", () => { + expect(WebPlatform.resolveAsset(42)).toBe("/assets/font-42.ttf"); + }); + it("resolves an ES module with a string default export", () => { + expect( + WebPlatform.resolveAsset({ + __esModule: true, + default: "https://localhost/assets/font.ttf", + }) + ).toBe("https://localhost/assets/font.ttf"); + }); + it("resolves a module-shaped source the same as the direct value", () => { + expect( + WebPlatform.resolveAsset({ default: metroAsset } as DataModule) + ).toBe(WebPlatform.resolveAsset(metroAsset)); + expect(WebPlatform.resolveAsset({ default: 42 } as DataModule)).toBe( + WebPlatform.resolveAsset(42) + ); + }); + it("resolves a plain string source as-is", () => { + expect( + WebPlatform.resolveAsset( + "https://localhost/assets/font.ttf" as unknown as DataModule + ) + ).toBe("https://localhost/assets/font.ttf"); + }); + }); + describe("native", () => { + it("resolves a Metro asset", () => { + expect(NativePlatform.resolveAsset(metroAsset)).toBe(metroAsset.uri); + }); + it("resolves a module id via resolveAssetSource", () => { + expect(NativePlatform.resolveAsset(42)).toBe("asset://42"); + }); + it("resolves a module-shaped source the same as the direct value", () => { + expect( + NativePlatform.resolveAsset({ default: metroAsset } as DataModule) + ).toBe(NativePlatform.resolveAsset(metroAsset)); + expect(NativePlatform.resolveAsset({ default: 42 } as DataModule)).toBe( + NativePlatform.resolveAsset(42) + ); + }); + }); +}); diff --git a/packages/skia/src/renderer/__tests__/e2e/Color.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/Color.spec.tsx index 6aa8458190..4691f5947e 100644 --- a/packages/skia/src/renderer/__tests__/e2e/Color.spec.tsx +++ b/packages/skia/src/renderer/__tests__/e2e/Color.spec.tsx @@ -91,6 +91,42 @@ describe("Skia.Color", () => { expect(result).toBe(true); }); + it("should convert number arrays exactly like Float32Array, string and number colors", async () => { + const result = await surface.eval((Skia) => { + const fromArray = Skia.Color([1, 0, 0, 1]); + const fromTypedArray = Skia.Color(new Float32Array([1, 0, 0, 1])); + const fromString = Skia.Color("red"); + const fromNumber = Skia.Color(0xffff0000); + return { + isFloat32Array: fromArray instanceof Float32Array, + fromArray: Array.from(fromArray), + fromTypedArray: Array.from(fromTypedArray), + fromString: Array.from(fromString), + fromNumber: Array.from(fromNumber), + }; + }); + expect(result.isFloat32Array).toBe(true); + expect(result.fromArray).toEqual(result.fromTypedArray); + expect(result.fromArray).toEqual(result.fromString); + expect(result.fromArray).toEqual(result.fromNumber); + }); + + it("should round-trip fractional number arrays like Float32Array", async () => { + const result = await surface.eval((Skia) => { + const fromArray = Skia.Color([0.1, 0.2, 0.3, 1]); + const fromTypedArray = Skia.Color(new Float32Array([0.1, 0.2, 0.3, 1])); + return { + fromArray: Array.from(fromArray), + fromTypedArray: Array.from(fromTypedArray), + }; + }); + expect(result.fromArray).toEqual(result.fromTypedArray); + expect(result.fromArray[0]).toBeCloseTo(0.1); + expect(result.fromArray[1]).toBeCloseTo(0.2); + expect(result.fromArray[2]).toBeCloseTo(0.3); + expect(result.fromArray[3]).toBeCloseTo(1); + }); + it("should pass through Float32Array unchanged", async () => { const result = await surface.eval((Skia) => { const input = Float32Array.of(0.5, 0.5, 0.5, 1); diff --git a/packages/skia/src/skia/__tests__/Font.spec.ts b/packages/skia/src/skia/__tests__/Font.spec.ts new file mode 100644 index 0000000000..09f60bc8c6 --- /dev/null +++ b/packages/skia/src/skia/__tests__/Font.spec.ts @@ -0,0 +1,63 @@ +import { loadFont } from "../../renderer/__tests__/setup"; + +import { setupSkia } from "./setup"; + +describe("Font API", () => { + it("measureText doesn't throw on web", () => { + setupSkia(); + const font = loadFont("skia/__tests__/assets/Roboto-Medium.ttf", 32); + expect(() => font.measureText("Hello World")).not.toThrow(); + const rect = font.measureText("Hello World"); + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + // The ink bounds start above the baseline. + expect(rect.y).toBeLessThan(0); + }); + + it("measureText width grows with the text length", () => { + setupSkia(); + const font = loadFont("skia/__tests__/assets/Roboto-Medium.ttf", 32); + const one = font.measureText("A").width; + const two = font.measureText("AA").width; + const three = font.measureText("AAA").width; + expect(two).toBeGreaterThan(one); + expect(three).toBeGreaterThan(two); + // "AAA" spans two full advances plus the ink width of the last "A", + // so it is roughly three times the width of a single "A". + expect(three).toBeGreaterThan(2 * one); + expect(three).toBeLessThan(4 * one); + }); + + it("measureText is consistent with the sum of the glyph advances", () => { + setupSkia(); + const fontSize = 32; + const font = loadFont("skia/__tests__/assets/Roboto-Medium.ttf", fontSize); + const text = "Hello World"; + const advances = font + .getGlyphWidths(font.getGlyphIDs(text)) + .reduce((a, b) => a + b, 0); + const rect = font.measureText(text); + // The ink bounds are contained within the advance width (modulo bearings) + // and shouldn't differ from it by more than a glyph's worth of bearing. + expect(rect.x + rect.width).toBeLessThanOrEqual(advances); + expect(rect.width).toBeGreaterThan(advances - fontSize); + }); + + it("measureText returns an empty rect for an empty string", () => { + setupSkia(); + const font = loadFont("skia/__tests__/assets/Roboto-Medium.ttf", 32); + const rect = font.measureText(""); + expect(rect.x).toBe(0); + expect(rect.y).toBe(0); + expect(rect.width).toBe(0); + expect(rect.height).toBe(0); + }); + + it("measureText returns an empty rect for whitespace only", () => { + setupSkia(); + const font = loadFont("skia/__tests__/assets/Roboto-Medium.ttf", 32); + const rect = font.measureText(" "); + expect(rect.width).toBe(0); + expect(rect.height).toBe(0); + }); +}); diff --git a/packages/skia/src/skia/__tests__/MatchFont.spec.ts b/packages/skia/src/skia/__tests__/MatchFont.spec.ts new file mode 100644 index 0000000000..6c47aff9c5 --- /dev/null +++ b/packages/skia/src/skia/__tests__/MatchFont.spec.ts @@ -0,0 +1,154 @@ +import fs from "fs"; +import path from "path"; + +// Type-level check: the font style types used by matchFont are exported +// from the package root (https://github.com/Shopify/react-native-skia/issues/3491) +import type { RNFontStyle, RNFontSlant, RNFontWeight } from "../../index"; +// Type-only import: core/Font captures the global Skia object at module +// evaluation time, so the module itself is imported lazily in beforeAll. +import type { matchFont as matchFontType } from "../core/Font"; +import { LoadSkiaWeb } from "../../web/LoadSkiaWeb"; +import { FontSlant, FontWeight } from "../types"; +import type { FontStyle, SkFontMgr, SkTypeface } from "../types"; +import { JsiSkApi } from "../web"; + +jest.mock("react-native", () => ({ + PixelRatio: { + get(): number { + return 1; + }, + }, + Platform: { OS: "web" }, + Image: { + resolveAssetSource: jest.fn, + }, +})); + +let Skia: ReturnType; +let matchFont: typeof matchFontType; +let typeface: SkTypeface; + +const capturedStyles: FontStyle[] = []; +const capturedFamilies: string[] = []; + +// matchFamilyStyle is not implemented by CanvasKit on web, so we use a +// stub font manager that records the style resolved by matchFont and +// returns a real typeface. +const makeFontMgr = (): SkFontMgr => + ({ + countFamilies: () => 1, + getFamilyName: () => "Roboto", + matchFamilyStyle: (name: string, style: FontStyle) => { + capturedFamilies.push(name); + capturedStyles.push(style); + return typeface; + }, + }) as unknown as SkFontMgr; + +const lastStyle = () => capturedStyles[capturedStyles.length - 1]; + +beforeAll(async () => { + await LoadSkiaWeb(); + Skia = JsiSkApi(global.CanvasKit); + global.SkiaApi = Skia; + // core/Font captures the global Skia object at module evaluation time, + // so it needs to be imported once the global has been set. + ({ matchFont } = await import("../core/Font")); + const data = Skia.Data.fromBytes( + fs.readFileSync(path.resolve(__dirname, "./assets/Roboto-Medium.ttf")) + ); + typeface = Skia.Typeface.MakeFreeTypeFaceFromData(data)!; + expect(typeface).toBeTruthy(); +}); + +describe("matchFont", () => { + it("applies the documented default font style", () => { + const fontMgr = makeFontMgr(); + const font = matchFont(undefined, fontMgr); + expect(font.getSize()).toBe(14); + expect(capturedFamilies[capturedFamilies.length - 1]).toBe("System"); + expect(lastStyle()).toEqual({ + weight: FontWeight.Normal, + width: 5, + slant: FontSlant.Upright, + }); + }); + + it("accepts React Native string weights", () => { + const fontMgr = makeFontMgr(); + const font = matchFont( + { fontFamily: "Roboto", fontSize: 16, fontWeight: "bold" }, + fontMgr + ); + expect(font.getSize()).toBe(16); + expect(capturedFamilies[capturedFamilies.length - 1]).toBe("Roboto"); + expect(lastStyle().weight).toBe(FontWeight.Bold); + }); + + it("accepts FontWeight enum values used by the Paragraph API", () => { + const fontMgr = makeFontMgr(); + matchFont({ fontWeight: FontWeight.Medium }, fontMgr); + expect(lastStyle().weight).toBe(500); + matchFont({ fontWeight: 300 }, fontMgr); + expect(lastStyle().weight).toBe(FontWeight.Light); + }); + + it("resolves string weights and FontWeight enum values identically", () => { + const fontMgr = makeFontMgr(); + const pairs: [RNFontWeight, FontWeight][] = [ + ["normal", FontWeight.Normal], + ["bold", FontWeight.Bold], + ["100", FontWeight.Thin], + ["200", FontWeight.ExtraLight], + ["300", FontWeight.Light], + ["400", FontWeight.Normal], + ["500", FontWeight.Medium], + ["600", FontWeight.SemiBold], + ["700", FontWeight.Bold], + ["800", FontWeight.ExtraBold], + ["900", FontWeight.Black], + ]; + pairs.forEach(([str, enumValue]) => { + matchFont({ fontWeight: str }, fontMgr); + const fromString = lastStyle().weight; + matchFont({ fontWeight: enumValue }, fontMgr); + const fromEnum = lastStyle().weight; + expect(fromString).toBe(enumValue); + expect(fromEnum).toBe(enumValue); + }); + }); + + it("accepts both string and FontSlant enum values for fontStyle", () => { + const fontMgr = makeFontMgr(); + const pairs: [RNFontSlant, FontSlant][] = [ + ["normal", FontSlant.Upright], + ["italic", FontSlant.Italic], + ["oblique", FontSlant.Oblique], + [FontSlant.Italic, FontSlant.Italic], + [FontSlant.Oblique, FontSlant.Oblique], + [FontSlant.Upright, FontSlant.Upright], + ]; + pairs.forEach(([input, expected]) => { + matchFont({ fontStyle: input }, fontMgr); + expect(lastStyle().slant).toBe(expected); + }); + }); + + it("accepts a style object shared with the Paragraph API", () => { + const fontMgr = makeFontMgr(); + // The same values can be used both with matchFont and in a Paragraph + // TextStyle without any conversion. + const labelFont: Partial = { + fontSize: 16, + fontWeight: FontWeight.Medium, + fontStyle: FontSlant.Italic, + }; + const font = matchFont(labelFont, fontMgr); + expect(font.getSize()).toBe(16); + expect(lastStyle()).toEqual({ + weight: FontWeight.Medium, + width: 5, + slant: FontSlant.Italic, + }); + }); +}); diff --git a/packages/skia/src/skia/core/Font.ts b/packages/skia/src/skia/core/Font.ts index e5e1c2a505..926debd196 100644 --- a/packages/skia/src/skia/core/Font.ts +++ b/packages/skia/src/skia/core/Font.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Skia } from "../Skia"; -import { FontSlant } from "../types"; +import { FontSlant, FontWeight } from "../types"; import type { DataModule, DataSourceParam, SkFontMgr } from "../types"; import { Platform } from "../../Platform"; import type { SkTypefaceFontProvider } from "../types/Paragraph/TypefaceFontProvider"; @@ -29,8 +29,18 @@ export const useFont = ( }, [size, typeface]); }; -type Slant = "normal" | "italic" | "oblique"; -type Weight = +/** + * React Native style font slant, as found in the `fontStyle` property of + * `TextStyle`. The Skia {@link FontSlant} enum is accepted as well. + */ +export type RNFontSlant = "normal" | "italic" | "oblique" | FontSlant; + +/** + * React Native style font weight, as found in the `fontWeight` property of + * `TextStyle`. Numeric weights such as the {@link FontWeight} enum values + * used by the Paragraph API are accepted as well. + */ +export type RNFontWeight = | "normal" | "bold" | "100" @@ -41,13 +51,20 @@ type Weight = | "600" | "700" | "800" - | "900"; + | "900" + | FontWeight; -interface RNFontStyle { +/** + * Font style accepted by {@link matchFont}. + * `fontStyle` and `fontWeight` accept both the React Native string values + * and the Skia enums ({@link FontSlant} and {@link FontWeight}), so the same + * values can be shared with the Paragraph API. + */ +export interface RNFontStyle { fontFamily: string; fontSize: number; - fontStyle: Slant; - fontWeight: Weight; + fontStyle: RNFontSlant; + fontWeight: RNFontWeight; } const defaultFontStyle: RNFontStyle = { @@ -57,8 +74,10 @@ const defaultFontStyle: RNFontStyle = { fontWeight: "normal", }; -const slant = (s: Slant) => { - if (s === "italic") { +const slant = (s: RNFontSlant): FontSlant => { + if (typeof s === "number") { + return s; + } else if (s === "italic") { return FontSlant.Italic; } else if (s === "oblique") { return FontSlant.Oblique; @@ -67,14 +86,16 @@ const slant = (s: Slant) => { } }; -const weight = (fontWeight: Weight) => { +const weight = (fontWeight: RNFontWeight): FontWeight => { switch (fontWeight) { case "normal": - return 400; + return FontWeight.Normal; case "bold": - return 700; + return FontWeight.Bold; default: - return parseInt(fontWeight, 10); + return typeof fontWeight === "number" + ? fontWeight + : (parseInt(fontWeight, 10) as FontWeight); } }; diff --git a/packages/skia/src/skia/types/Data/Data.ts b/packages/skia/src/skia/types/Data/Data.ts index 226050fe4e..1b1b47ea90 100644 --- a/packages/skia/src/skia/types/Data/Data.ts +++ b/packages/skia/src/skia/types/Data/Data.ts @@ -3,15 +3,15 @@ import type { SkJSIInstance } from "../JsiInstance"; export type SkData = SkJSIInstance<"Data">; type RNModule = number; -type ESModule = { - __esModule: true; - default: string; -}; type MetroAsset = { uri: string; width: number; height: number; }; +type ESModule = { + __esModule: true; + default: RNModule | MetroAsset | string; +}; export type DataModule = RNModule | ESModule | MetroAsset; export type DataSource = DataModule | string | Uint8Array; @@ -19,3 +19,13 @@ export type DataSourceParam = DataSource | null | undefined; export const isRNModule = (mod: DataModule): mod is RNModule => typeof mod === "number"; + +// Since Expo SDK 52, on web, require() may return an ES module interop +// object ({ default: }) instead of the asset itself. +// See https://github.com/Shopify/react-native-skia/issues/2784 +export const unwrapModule = ( + mod: DataModule +): RNModule | MetroAsset | string => + typeof mod === "object" && mod !== null && "default" in mod + ? mod.default + : mod; diff --git a/packages/skia/src/skia/web/JsiSkFont.ts b/packages/skia/src/skia/web/JsiSkFont.ts index a350114553..167108b4da 100644 --- a/packages/skia/src/skia/web/JsiSkFont.ts +++ b/packages/skia/src/skia/web/JsiSkFont.ts @@ -1,4 +1,4 @@ -import type { CanvasKit, Font } from "canvaskit-wasm"; +import type { CanvasKit, Font, Paint } from "canvaskit-wasm"; import type { FontEdging, @@ -10,7 +10,7 @@ import type { SkTypeface, } from "../types"; -import { HostObject, getEnum, throwNotImplementedOnRNWeb } from "./Host"; +import { HostObject, getEnum } from "./Host"; import { JsiSkPaint } from "./JsiSkPaint"; import { JsiSkPoint } from "./JsiSkPoint"; import { JsiSkRect } from "./JsiSkRect"; @@ -21,8 +21,52 @@ export class JsiSkFont extends HostObject implements SkFont { super(CanvasKit, ref, "Font"); } - measureText(_text: string, _paint?: SkPaint | undefined) { - return throwNotImplementedOnRNWeb(); + measureText(text: string, paint?: SkPaint | undefined): SkRect { + // CanvasKit doesn't expose SkFont::measureText directly, so we polyfill + // it: for each glyph we take its ink bounds (relative to its own origin), + // offset it by the accumulated advance, and union the results. This + // matches the bounds computed natively by SkFont::measureText. + const glyphs = this.ref.getGlyphIDs(text); + if (glyphs.length === 0) { + return new JsiSkRect(this.CanvasKit, this.CanvasKit.XYWHRect(0, 0, 0, 0)); + } + const skPaint = paint ? JsiSkPaint.fromValue(paint) : null; + // Flattened rectangles: 4 floats (left, top, right, bottom) per glyph. + const bounds = this.ref.getGlyphBounds(glyphs, skPaint); + const advances = this.ref.getGlyphWidths(glyphs, skPaint); + let left = 0; + let top = 0; + let right = 0; + let bottom = 0; + let isEmpty = true; + let xPos = 0; + for (let i = 0; i < glyphs.length; i++) { + const l = bounds[i * 4] + xPos; + const t = bounds[i * 4 + 1]; + const r = bounds[i * 4 + 2] + xPos; + const b = bounds[i * 4 + 3]; + xPos += advances[i]; + // Skip empty glyph bounds (e.g. whitespace), like SkRect::join does. + if (l >= r || t >= b) { + continue; + } + if (isEmpty) { + left = l; + top = t; + right = r; + bottom = b; + isEmpty = false; + } else { + left = Math.min(left, l); + top = Math.min(top, t); + right = Math.max(right, r); + bottom = Math.max(bottom, b); + } + } + return new JsiSkRect( + this.CanvasKit, + this.CanvasKit.LTRBRect(left, top, right, bottom) + ); } getTextWidth(text: string, paint?: SkPaint | undefined) {