From 75ac5a2b77fd182a3925e72e7168da39716b7f3e Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 11:13:07 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(=F0=9F=93=9D):=20unify=20matchFont=20a?= =?UTF-8?q?nd=20Paragraph=20font=20style=20types=20(#3948)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matchFont now also accepts the FontWeight and FontSlant enums used by the Paragraph API for its fontWeight and fontStyle attributes, so the same style values can be shared between both APIs without manual conversion. The previously internal font style types are now exported as RNFontStyle, RNFontWeight, and RNFontSlant so users can type their own helpers. Fixes #3491 --- apps/docs/docs/text/text.md | 22 ++- .../skia/src/skia/__tests__/MatchFont.spec.ts | 154 ++++++++++++++++++ packages/skia/src/skia/core/Font.ts | 47 ++++-- 3 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 packages/skia/src/skia/__tests__/MatchFont.spec.ts 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/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); } }; From 7e5b63ec9d42c0bf01873f192697b6a88e020212 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 11:14:35 +0200 Subject: [PATCH 2/6] =?UTF-8?q?test(=F0=9F=8E=A8):=20assert=20Skia.Color(n?= =?UTF-8?q?umber[])=20matches=20Float32Array/string/number=20colors=20(#39?= =?UTF-8?q?45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native conversion of plain number arrays in Skia.Color() was fixed in af4070bbe (#3719): JsiSkColor.h createCtor now converts a 4-element JS array into a Float32Array, and JsiSkColor::fromValue as well as the recorder Convertor.h accept plain arrays. The web implementation already converted arrays via new Float32Array(color). These tests lock in the behavior reported in the issue: Skia.Color([r,g,b,a]) (0-1 floats) round-trips identically to Skia.Color(new Float32Array([...])) and to the equivalent string and ARGB number colors, on both web and native. Fixes #2200 --- .../src/renderer/__tests__/e2e/Color.spec.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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); From 7deb9251faa563830b854ce9bd1d78481eb94ea7 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 12:04:41 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(=F0=9F=A4=96):=20fail=20gracefully=20wh?= =?UTF-8?q?en=20the=20OpenGL=20context=20or=20surface=20can't=20be=20creat?= =?UTF-8?q?ed=20(#3950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android 10 devices with PowerVR GE8320 GPUs (Redmi 9C/9A, Oppo A15, Realme C11, ...), EGL context/surface creation can fail and the null result was dereferenced in gl::Context::makeCurrent and OpenGLWindowContext, crash-looping the app with SIGSEGV on the first render. Check every EGL creation result, log the error, and skip rendering instead of crashing. Fixes #3793 Relates to #3156 --- .../cpp/rnskia-android/OpenGLContext.h | 32 +++++++++++++++++-- .../rnskia-android/OpenGLWindowContext.cpp | 11 +++++-- .../android/cpp/rnskia-android/gl/Context.h | 3 ++ .../android/cpp/rnskia-android/gl/Surface.h | 2 +- packages/skia/cpp/api/JsiSkiaContext.h | 4 +++ packages/skia/cpp/rnskia/RNSkView.h | 6 ++++ 6 files changed, 52 insertions(+), 6 deletions(-) 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; }; From ab2c428ca76b003efc2c08867c3438a0330f085a Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 12:51:43 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(=F0=9F=8C=90):=20implement=20Font.measu?= =?UTF-8?q?reText=20on=20Web=20(#3947)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CanvasKit doesn't expose SkFont::measureText, so measureText threw NotImplemented on React Native Web. Polyfill it with the same semantics as the native implementation: get the glyph IDs, offset each glyph's ink bounds (getGlyphBounds) by the accumulated advances (getGlyphWidths), and union the results into a single bounding rect. Fixes #2639 Co-authored-by: Claude Fable 5 --- packages/skia/src/skia/__tests__/Font.spec.ts | 63 +++++++++++++++++++ packages/skia/src/skia/web/JsiSkFont.ts | 52 +++++++++++++-- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/skia/src/skia/__tests__/Font.spec.ts 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/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) { From d07eacb4102ccf3cbdf31743f2803373bc379877 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 12:52:10 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix(=F0=9F=8C=8E):=20resolve=20ES=20module?= =?UTF-8?q?=20interop=20font/image=20assets=20(#2784)=20(#3946)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Expo SDK 52, on web, require("./font.ttf") can return an ES module interop object ({ default: }) instead of the asset itself, so Platform.resolveAsset returned undefined (or the wrapper object) and useFonts/useFont failed with "Couldn't create typeface". Unwrap { default: ... } module objects in resolveAsset before applying the existing resolution logic, and pass string URIs through as-is. The guard is applied to both the web and native resolvers so that module-wrapped sources (e.g. the { default: require(...) } workaround users adopted) resolve consistently on every platform, for fonts, images and SVGs alike. Fixes #2784 Co-authored-by: Claude Fable 5 --- packages/skia/src/Platform/Platform.ts | 13 ++- packages/skia/src/Platform/Platform.web.tsx | 17 ++-- .../src/Platform/__tests__/Platform.spec.ts | 83 +++++++++++++++++++ packages/skia/src/skia/types/Data/Data.ts | 18 +++- 4 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 packages/skia/src/Platform/__tests__/Platform.spec.ts 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/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; From 58b53039fbf963fefee5b6d5ee87091c1c11b466 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Thu, 16 Jul 2026 12:52:49 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(=F0=9F=97=BF):=20fix=20graphite=20relea?= =?UTF-8?q?se=20(#3951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The next release runs the graphite setup (bakes Dawn/Graphite headers into cpp/, creates the libs/.graphite marker shipped via the files field) and swaps the standard binary packages for react-native-skia-graphite-*. The podspec and build.gradle now resolve prebuilt binaries from the graphite npm packages when the marker is present, falling back to libs/ for in-repo development where install-skia-graphite downloads them directly. main and next stay identical; the channel difference lives in build-npm.yml only. --- .github/workflows/build-npm.yml | 23 ++++++++++++++++++++ packages/skia/android/build.gradle | 14 +++++++++---- packages/skia/package.json | 8 ++++++- packages/skia/react-native-skia.podspec | 28 ++++++++++++++++--------- 4 files changed, 58 insertions(+), 15 deletions(-) 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/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/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