diff --git a/docs/docs/text/paragraph.md b/docs/docs/text/paragraph.md new file mode 100644 index 0000000000..2aa62e4ec8 --- /dev/null +++ b/docs/docs/text/paragraph.md @@ -0,0 +1,173 @@ +--- +id: paragraph +title: Paragraph +sidebar_label: Paragraph +slug: /text/paragraph +--- + +React Native Skia offers an API to perform text layouts. +Behind the scene, this API is the Skia Paragraph API. + + +## Hello World + +In the example below, we create a simple paragraph based on custom fonts. +The emojis will be renderer using the emoji font available on the platform. +Other system fonts will are available as well. + +```tsx twoslash +import { useMemo } from "react"; +import { Paragraph, Skia, useFonts } from "@shopify/react-native-skia"; + +const MyParagraph = () => { + const customFontMgr = useFonts({ + Roboto: [ + require("path/to/Roboto-Regular.ttf"), + require("path/to/Roboto-Medium.ttf") + ] + }); + + const paragraph = useMemo(() => { + // Are the font loaded already? + if (!customFontMgr) { + return null; + } + const textStyle = { + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 50, + }; + return Skia.ParagraphBuilder.Make({}, customFontMgr) + .pushStyle(textStyle) + .addText("Say Hello to ") + .pushStyle({ ...textStyle, fontStyle: { weight: 500 } }) + .addText("Skia 🎨") + .pop() + .build(); + }, [customFontMgr]); + + // Render the paragraph + return ; +}; +``` + +Below is the result on Android (left) and iOS (right). + + + +On Web, you will need to provide you own emoji font ([NotoColorEmoji](https://fonts.google.com/noto/specimen/Noto+Color+Emoji) for instance) and add it to the list of font families. + +```tsx twoslash +import { useFonts, Skia } from "@shopify/react-native-skia"; + +const customFontMgr = useFonts({ + Roboto: [ + require("path/to/Roboto-Regular.ttf"), + require("path/to/Roboto-Medium.ttf") + ], + // Only load the emoji font on Web + Noto: [ + require("path/to/NotoColorEmoji.ttf") + ] +}); + +// We add Noto to the list of font families +const textStyle = { + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto"], + fontSize: 50, +}; +``` + +## Styling Paragraphs + +These properties define the overall layout and behavior of a paragraph. + +| Property | Description | +|-------------------------|---------------------------------------------------------------------------------------| +| `disableHinting` | Controls whether text hinting is disabled. | +| `ellipsis` | Specifies the text to use for ellipsis when text overflows. | +| `heightMultiplier` | Sets the line height as a multiplier of the font size. | +| `maxLines` | Maximum number of lines for the paragraph. | +| `replaceTabCharacters` | Determines whether tab characters should be replaced with spaces. | +| `strutStyle` | Defines the strut style, which affects the minimum height of a line. | +| `textAlign` | Sets the alignment of text (left, right, center, justify, start, end). | +| `textDirection` | Determines the text direction (RTL or LTR). | +| `textHeightBehavior` | Controls the behavior of text ascent and descent in the first and last lines. | +| `textStyle` | Default text style for the paragraph (can be overridden by individual text styles). | + +### Text Style Properties + +These properties are used to style specific segments of text within a paragraph. + +| Property | Description | +|-----------------------|-------------------------------------------------------------------------------------| +| `backgroundColor` | Background color of the text. | +| `color` | Color of the text. | +| `decoration` | Type of text decoration (underline, overline, line-through). | +| `decorationColor` | Color of the text decoration. | +| `decorationThickness` | Thickness of the text decoration. | +| `decorationStyle` | Style of the text decoration (solid, double, dotted, dashed, wavy). | +| `fontFamilies` | List of font families for the text. | +| `fontFeatures` | List of font features. | +| `fontSize` | Font size of the text. | +| `fontStyle` | Font style (weight, width, slant). | +| `fontVariations` | Font variations. | +| `foregroundColor` | Foreground color (for effects like gradients). | +| `heightMultiplier` | Multiplier for line height. | +| `halfLeading` | Controls half-leading value. | +| `letterSpacing` | Space between characters. | +| `locale` | Locale for the text (affects things like sorting). | +| `shadows` | List of text shadows. | +| `textBaseline` | Baseline for the text (alphabetic, ideographic). | +| `wordSpacing` | Space between words. | + +These tables offer a quick reference to differentiate between paragraph and text styles in React Native Skia. You can use them to guide developers on how to apply various styles to create visually appealing and functional text layouts. +Below is an example using different font styling: + +```tsx twoslash +import { useMemo } from "react"; +import { Paragraph, Skia, useFonts, FontStyle } from "@shopify/react-native-skia"; + +const MyParagraph = () => { + const customFontMgr = useFonts({ + Roboto: [ + require("path/to/Roboto-Italic.ttf"), + require("path/to/Roboto-Regular.ttf"), + require("path/to/Roboto-Bold.ttf") + ], + }); + + const paragraph = useMemo(() => { + // Are the custom fonts loaded? + if (!customFontMgr) { + return null; + } + const textStyle = { + fontSize: 24, + fontFamilies: ["Roboto"], + color: Skia.Color("#000"), + }; + + const paragraphBuilder = Skia.ParagraphBuilder.Make({}, customFontMgr); + paragraphBuilder + .pushStyle({ ...textStyle, fontStyle: FontStyle.Bold }) + .addText("This text is bold\n") + .pop() + .pushStyle({ ...textStyle, fontStyle: FontStyle.Normal }) + .addText("This text is regular\n") + .pop() + .pushStyle({ ...textStyle, fontStyle: FontStyle.Italic }) + .addText("This text is italic") + .pop() + .build(); + return paragraphBuilder.build(); + }, [customFontMgr]); + + return ; +}; +``` + +#### Result + + diff --git a/docs/sidebars.js b/docs/sidebars.js index c0a2c8c0c6..2652e761dd 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -69,6 +69,7 @@ const sidebars = { label: "Text", items: [ "text/fonts", + "text/paragraph", "text/text", "text/glyphs", "text/path", diff --git a/docs/static/img/paragraph/font-style-android.png b/docs/static/img/paragraph/font-style-android.png new file mode 100644 index 0000000000..932675442e Binary files /dev/null and b/docs/static/img/paragraph/font-style-android.png differ diff --git a/docs/static/img/paragraph/font-style-ios.png b/docs/static/img/paragraph/font-style-ios.png new file mode 100644 index 0000000000..1495566c17 Binary files /dev/null and b/docs/static/img/paragraph/font-style-ios.png differ diff --git a/docs/static/img/paragraph/font-style-node.png b/docs/static/img/paragraph/font-style-node.png new file mode 100644 index 0000000000..909ba36e10 Binary files /dev/null and b/docs/static/img/paragraph/font-style-node.png differ diff --git a/docs/static/img/paragraph/hello-world-android.png b/docs/static/img/paragraph/hello-world-android.png new file mode 100644 index 0000000000..dea7f6f7ca Binary files /dev/null and b/docs/static/img/paragraph/hello-world-android.png differ diff --git a/docs/static/img/paragraph/hello-world-ios.png b/docs/static/img/paragraph/hello-world-ios.png new file mode 100644 index 0000000000..95aaba8433 Binary files /dev/null and b/docs/static/img/paragraph/hello-world-ios.png differ diff --git a/docs/static/img/paragraph/hello-world-node.png b/docs/static/img/paragraph/hello-world-node.png new file mode 100644 index 0000000000..d03316fccd Binary files /dev/null and b/docs/static/img/paragraph/hello-world-node.png differ diff --git a/example/ios/Podfile b/example/ios/Podfile index 084ae16fd1..ece047e0f2 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -57,5 +57,12 @@ target 'RNSkia' do :mac_catalyst_enabled => false ) __apply_Xcode_12_5_M1_post_install_workaround(installer) + + # Fix issue with No template named 'unary_function' in namespace in XCode 15 + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', '_LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION'] + end + end end end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 79fc807a19..56021f0b80 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -689,6 +689,6 @@ SPEC CHECKSUMS: Yoga: d56980c8914db0b51692f55533409e844b66133c YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 131fa288ba44bcbe1eff8b491f2c5a7f95d8f3a8 +PODFILE CHECKSUM: 3bf5d3cf7d3fe342535cb3ff9d97c0af632863ce COCOAPODS: 1.11.3 diff --git a/example/src/Examples/API/List.tsx b/example/src/Examples/API/List.tsx index 6231acdc9d..c03d5c5103 100644 --- a/example/src/Examples/API/List.tsx +++ b/example/src/Examples/API/List.tsx @@ -18,6 +18,10 @@ export const examples = [ screen: "AnimatedImages", title: "🌅 Animated Images", }, + { + screen: "Paragraphs", + title: "📚 Text & Paragraphs", + }, { screen: "Clipping", title: "✂️ & 🎭 Clipping & Masking", diff --git a/example/src/Examples/API/Paragraphs.tsx b/example/src/Examples/API/Paragraphs.tsx new file mode 100644 index 0000000000..f854690d32 --- /dev/null +++ b/example/src/Examples/API/Paragraphs.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useMemo } from "react"; +import { Platform, ScrollView, useWindowDimensions } from "react-native"; +import type { DataModule, SkTextStyle } from "@shopify/react-native-skia"; +import { + Canvas, + FontSlant, + FontWeight, + Group, + PaintStyle, + Paragraph, + Rect, + Skia, + TextDecoration, + mix, + useFonts, +} from "@shopify/react-native-skia"; +import { + useSharedValue, + useDerivedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +const fonts: Record = { + Roboto: [ + require("../../Tests/assets/Roboto-Medium.ttf"), + require("../../Tests/assets/Roboto-Regular.ttf"), + ], +}; +// On Web, we need provide a font for emojis +if (Platform.OS === "web") { + fonts.NotoColorEmoji = [require("../../Tests/assets/NotoColorEmoji.ttf")]; +} + +export const Paragraphs = () => { + const { height, width } = useWindowDimensions(); + const progress = useSharedValue(1); + + useEffect(() => { + progress.value = withRepeat(withTiming(0, { duration: 3000 }), -1, true); + }, [progress]); + + const loopedWidth = useDerivedValue( + () => mix(progress.value, width * 0.2, width * 0.8), + [progress] + ); + + const customFontMgr = useFonts(fonts); + + const paragraph = useMemo(() => { + if (customFontMgr === null) { + return null; + } + const fontSize = 20; + const paragraphBuilder = Skia.ParagraphBuilder.Make({}, customFontMgr); + const strokePaint = Skia.Paint(); + strokePaint.setStyle(PaintStyle.Stroke); + strokePaint.setStrokeWidth(1); + + const textStyle = { + fontSize, + fontFamilies: ["Roboto", "NotoColorEmoji"], + color: Skia.Color("#000"), + }; + + const coloredTextStyle = { + fontSize: fontSize * 1.3, + fontFamilies: ["Roboto"], + fontStyle: { + weight: FontWeight.Medium, + }, + color: Skia.Color("#61bea2"), + }; + + const crazyStyle: SkTextStyle = { + color: Skia.Color("#000"), + backgroundColor: Skia.Color("#CECECE"), + fontSize: fontSize * 1.3, + fontFamilies: ["Roboto"], + letterSpacing: -1, + wordSpacing: 20, + fontStyle: { + slant: FontSlant.Italic, + weight: FontWeight.ExtraBlack, + }, + shadows: [ + { + color: Skia.Color("#00000044"), + blurRadius: 4, + offset: { x: 4, y: 4 }, + }, + ], + decorationColor: Skia.Color("#00223A"), + decorationThickness: 2, + decoration: 1, + decorationStyle: TextDecoration.Overline, + }; + + paragraphBuilder + .pushStyle(textStyle) + .addText("Hello ") + .pushStyle({ + ...textStyle, + fontStyle: { + weight: FontWeight.Medium, + }, + }) + .addText("Skia") + .pop() + .addText(" 🙋🏼‍♂️\n\nThis text rendered using the ") + .pushStyle(coloredTextStyle) + .addText("SkParagraph ") + .pop() + .addText("module with "); + + const altColoredTextStyle = { + ...coloredTextStyle, + color: Skia.Color("#f5a623"), + }; + + const retVal = paragraphBuilder + .pushStyle(altColoredTextStyle) + .addText("libgrapheme ") + .pop() + .addText("on iOS.") + .pushStyle(textStyle) + .addText( + "\n\nOn Android we use built-in ICU while on web we use CanvasKit's." + ) + .pop() + .pushStyle(crazyStyle, strokePaint) + .addText("\n\nWow - this is cool.") + .pop() + .build(); + + return retVal; + }, [customFontMgr]); + + return ( + + + + + + + + + ); +}; diff --git a/example/src/Examples/API/Routes.ts b/example/src/Examples/API/Routes.ts index ab93b8c9e1..a416837b2b 100644 --- a/example/src/Examples/API/Routes.ts +++ b/example/src/Examples/API/Routes.ts @@ -22,4 +22,5 @@ export type Routes = { OnLayout: undefined; Snapshot: undefined; IconsExample: undefined; + Paragraphs: undefined; }; diff --git a/example/src/Examples/API/index.tsx b/example/src/Examples/API/index.tsx index 9ed99dd491..641c15f1f3 100644 --- a/example/src/Examples/API/index.tsx +++ b/example/src/Examples/API/index.tsx @@ -25,6 +25,7 @@ import { Snapshot } from "./Snapshot"; import { IconsExample } from "./Icons"; import { FontMgr } from "./FontMgr"; import { AnimatedImages } from "./AnimatedImages"; +import { Paragraphs } from "./Paragraphs"; const Stack = createNativeStackNavigator(); export const API = () => { @@ -59,6 +60,13 @@ export const API = () => { title: "🌅 Animated Images", }} /> + { if (tree.code) { client.send( JSON.stringify( - // eslint-disable-next-line no-eval eval( `(function Main(){return (${tree.code})(this.Skia, this.ctx); })` ).call({ @@ -47,6 +48,21 @@ export const Tests = ({ assets }: TestsProps) => { }) ) ); + } else if (tree.paragraph) { + const paragraph = eval( + `(function Main(){return (${tree.paragraph})(this.Skia, this.ctx); })` + ).call({ + Skia, + ctx: parseProps(tree.ctx, assets), + }); + setDrawing( + + ); } else if (typeof tree.screen === "string") { const Screen = Screens[tree.screen]; if (!Screen) { @@ -67,7 +83,6 @@ export const Tests = ({ assets }: TestsProps) => { useEffect(() => { if (drawing) { const it = setTimeout(() => { - console.log("MakeImageSnapshot"); const image = ref.current?.makeImageSnapshot({ x: 0, y: 0, diff --git a/example/yarn.lock b/example/yarn.lock index 2bf6a2e656..4f7ba8f911 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -2991,6 +2991,11 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" +"@webgpu/types@0.1.21": + version "0.1.21" + resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.21.tgz#b181202daec30d66ccd67264de23814cfd176d3a" + integrity sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow== + "@webpack-cli/configtest@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.2.0.tgz#7b20ce1c12533912c3b217ea68262365fa29a6f5" @@ -3711,10 +3716,12 @@ caniuse-lite@^1.0.30001400: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz#5f1715e506e71860b4b07c50060ea6462217611e" integrity sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg== -canvaskit-wasm@0.38.2: - version "0.38.2" - resolved "https://registry.yarnpkg.com/canvaskit-wasm/-/canvaskit-wasm-0.38.2.tgz#b6c2be236670fd0f18977b9026652b2c0e201fee" - integrity sha512-ieRb6DO4yL91qUfyRgmyhp2Hi1KmQ9lIMfKacxHVlfp/CpKCkzgAxRGUbCsJFzwLKjs9fufGrIyvnzEYRwm1XQ== +canvaskit-wasm@0.39.1: + version "0.39.1" + resolved "https://registry.yarnpkg.com/canvaskit-wasm/-/canvaskit-wasm-0.39.1.tgz#c3c8f3962cbabbedf246f7bcf90e859013c7eae9" + integrity sha512-Gy3lCmhUdKq+8bvDrs9t8+qf7RvcjuQn+we7vTVVyqgOVO1UVfHpsnBxkTZw+R4ApEJ3D5fKySl9TU11hmjl/A== + dependencies: + "@webgpu/types" "0.1.21" cdt2d@^1.0.0: version "1.0.0" diff --git a/fabricexample/src/Tests/Tests.tsx b/fabricexample/src/Tests/Tests.tsx index c58fbdc581..ca0da04781 100644 --- a/fabricexample/src/Tests/Tests.tsx +++ b/fabricexample/src/Tests/Tests.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-eval */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { SkiaDomView } from "@shopify/react-native-skia"; import { @@ -5,6 +6,7 @@ import { Canvas, Skia, makeImageFromView, + Paragraph, } from "@shopify/react-native-skia"; import React, { useEffect, useRef, useState } from "react"; import { PixelRatio, Platform, Text, View } from "react-native"; @@ -38,7 +40,6 @@ export const Tests = ({ assets }: TestsProps) => { if (tree.code) { client.send( JSON.stringify( - // eslint-disable-next-line no-eval eval( `(function Main(){return (${tree.code})(this.Skia, this.ctx); })` ).call({ @@ -47,6 +48,21 @@ export const Tests = ({ assets }: TestsProps) => { }) ) ); + } else if (tree.paragraph) { + const paragraph = eval( + `(function Main(){return (${tree.paragraph})(this.Skia, this.ctx); })` + ).call({ + Skia, + ctx: parseProps(tree.ctx, assets), + }); + setDrawing( + + ); } else if (typeof tree.screen === "string") { const Screen = Screens[tree.screen]; if (!Screen) { @@ -67,7 +83,6 @@ export const Tests = ({ assets }: TestsProps) => { useEffect(() => { if (drawing) { const it = setTimeout(() => { - console.log("MakeImageSnapshot"); const image = ref.current?.makeImageSnapshot({ x: 0, y: 0, diff --git a/package/cpp/api/JsiSkApi.h b/package/cpp/api/JsiSkApi.h index 49464ba1bc..ef7073ff8b 100644 --- a/package/cpp/api/JsiSkApi.h +++ b/package/cpp/api/JsiSkApi.h @@ -24,6 +24,7 @@ #include "JsiSkMaskFilterFactory.h" #include "JsiSkMatrix.h" #include "JsiSkPaint.h" +#include "JsiSkParagraphBuilder.h" #include "JsiSkPath.h" #include "JsiSkPathEffect.h" #include "JsiSkPathEffectFactory.h" @@ -112,6 +113,10 @@ class JsiSkApi : public JsiSkHostObject { installReadonlyProperty( "TypefaceFontProvider", std::make_shared(context)); + + installReadonlyProperty( + "ParagraphBuilder", + std::make_shared(context)); } }; } // namespace RNSkia diff --git a/package/cpp/api/JsiSkFontMgrFactory.h b/package/cpp/api/JsiSkFontMgrFactory.h index 3aa5c06639..83ff16d844 100644 --- a/package/cpp/api/JsiSkFontMgrFactory.h +++ b/package/cpp/api/JsiSkFontMgrFactory.h @@ -22,13 +22,18 @@ namespace jsi = facebook::jsi; class JsiSkFontMgrFactory : public JsiSkHostObject { public: - JSI_HOST_FUNCTION(System) { - auto context = getContext(); + static sk_sp + getFontMgr(std::shared_ptr context) { static SkOnce once; static sk_sp fontMgr; - once([&context, &runtime] { fontMgr = context->createFontMgr(); }); + once([&context] { fontMgr = context->createFontMgr(); }); + return fontMgr; + } + + JSI_HOST_FUNCTION(System) { + auto fontMgr = JsiSkFontMgrFactory::getFontMgr(getContext()); return jsi::Object::createFromHostObject( - runtime, std::make_shared(std::move(context), fontMgr)); + runtime, std::make_shared(getContext(), fontMgr)); } JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiSkFontMgrFactory, System)) diff --git a/package/cpp/api/JsiSkParagraph.h b/package/cpp/api/JsiSkParagraph.h new file mode 100644 index 0000000000..99bbe0e38a --- /dev/null +++ b/package/cpp/api/JsiSkParagraph.h @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" + +#include "Paragraph.h" +#include "ParagraphBuilder.h" +#include "ParagraphStyle.h" + +#pragma clang diagnostic pop + +namespace RNSkia { + +namespace jsi = facebook::jsi; + +namespace para = skia::textlayout; + +/** + Implementation of the Paragraph object in JSI + */ +class JsiSkParagraph : public JsiSkHostObject { +public: + JSI_HOST_FUNCTION(layout) { + auto width = getArgumentAsNumber(runtime, arguments, count, 0); + _paragraph->layout(width); + return jsi::Value::undefined(); + } + + JSI_HOST_FUNCTION(paint) { + auto jsiCanvas = + getArgumentAsHostObject(runtime, arguments, count, 0); + auto x = getArgumentAsNumber(runtime, arguments, count, 1); + auto y = getArgumentAsNumber(runtime, arguments, count, 2); + _paragraph->paint(jsiCanvas->getCanvas(), x, y); + return jsi::Value::undefined(); + } + + JSI_HOST_FUNCTION(getHeight) { + return static_cast(_paragraph->getHeight()); + } + + JSI_HOST_FUNCTION(getMaxWidth) { + return static_cast(_paragraph->getMaxWidth()); + } + + JSI_HOST_FUNCTION(getGlyphPositionAtCoordinate) { + auto dx = getArgumentAsNumber(runtime, arguments, count, 0); + auto dy = getArgumentAsNumber(runtime, arguments, count, 1); + auto result = _paragraph->getGlyphPositionAtCoordinate(dx, dy); + return result.position; + } + + JSI_HOST_FUNCTION(getRectsForRange) { + auto start = getArgumentAsNumber(runtime, arguments, count, 0); + auto end = getArgumentAsNumber(runtime, arguments, count, 1); + auto result = + _paragraph->getRectsForRange(start, end, para::RectHeightStyle::kTight, + para::RectWidthStyle::kTight); + auto returnValue = jsi::Array(runtime, result.size()); + for (size_t i = 0; i < result.size(); ++i) { + returnValue.setValueAtIndex( + runtime, i, + JsiSkRect::toValue(runtime, getContext(), result[i].rect)); + } + return returnValue; + } + + JSI_HOST_FUNCTION(getLineMetrics) { + std::vector metrics; + _paragraph->getLineMetrics(metrics); + auto returnValue = jsi::Array(runtime, metrics.size()); + auto height = 0; + for (size_t i = 0; i < metrics.size(); ++i) { + returnValue.setValueAtIndex( + runtime, i, + JsiSkRect::toValue(runtime, getContext(), + SkRect::MakeXYWH(metrics[i].fLeft, height, + metrics[i].fWidth, + metrics[i].fHeight))); + height += metrics[i].fHeight; + } + return returnValue; + } + + JSI_HOST_FUNCTION(getRectsForPlaceholders) { + std::vector placeholderInfos = + _paragraph->getRectsForPlaceholders(); + auto returnValue = jsi::Array(runtime, placeholderInfos.size()); + for (size_t i = 0; i < placeholderInfos.size(); ++i) { + auto obj = jsi::Object(runtime); + obj.setProperty( + runtime, "rect", + JsiSkRect::toValue(runtime, getContext(), placeholderInfos[i].rect)); + obj.setProperty(runtime, "direction", + static_cast(placeholderInfos[i].direction)); + returnValue.setValueAtIndex(runtime, i, obj); + } + return returnValue; + } + + JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiSkParagraph, layout), + JSI_EXPORT_FUNC(JsiSkParagraph, paint), + JSI_EXPORT_FUNC(JsiSkParagraph, getMaxWidth), + JSI_EXPORT_FUNC(JsiSkParagraph, getHeight), + JSI_EXPORT_FUNC(JsiSkParagraph, getRectsForPlaceholders), + JSI_EXPORT_FUNC(JsiSkParagraph, + getGlyphPositionAtCoordinate), + JSI_EXPORT_FUNC(JsiSkParagraph, getRectsForRange), + JSI_EXPORT_FUNC(JsiSkParagraph, getLineMetrics)) + + explicit JsiSkParagraph(std::shared_ptr context, + para::ParagraphBuilder *paragraphBuilder) + : JsiSkHostObject(std::move(context)) { + _paragraph = paragraphBuilder->Build(); + } + + para::Paragraph *getParagraph() { return _paragraph.get(); } + +private: + std::unique_ptr _paragraph; +}; + +} // namespace RNSkia diff --git a/package/cpp/api/JsiSkParagraphBuilder.h b/package/cpp/api/JsiSkParagraphBuilder.h new file mode 100644 index 0000000000..76160cd75d --- /dev/null +++ b/package/cpp/api/JsiSkParagraphBuilder.h @@ -0,0 +1,159 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" + +#include "ParagraphBuilder.h" + +#pragma clang diagnostic pop + +namespace RNSkia { + +namespace jsi = facebook::jsi; + +namespace para = skia::textlayout; + +/** + Implementation of the ParagraphBuilder object in JSI + */ +class JsiSkParagraphBuilder : public JsiSkHostObject { +public: + JSI_API_TYPENAME("ParagraphBuilder"); + + JSI_HOST_FUNCTION(build) { + return jsi::Object::createFromHostObject( + runtime, + std::make_shared(getContext(), _builder.get())); + } + + JSI_HOST_FUNCTION(reset) { + _builder->Reset(); + return jsi::Value::undefined(); + } + + JSI_HOST_FUNCTION(addText) { + auto text = getArgumentAsString(runtime, arguments, count, 0).utf8(runtime); + _builder->addText(text.c_str()); + return thisValue.asObject(runtime); + } + + JSI_HOST_FUNCTION(addPlaceholder) { + auto width = + count >= 1 ? getArgumentAsNumber(runtime, arguments, count, 0) : 0; + auto height = + count >= 2 ? getArgumentAsNumber(runtime, arguments, count, 1) : 0; + auto alignment = + count >= 3 ? static_cast( + getArgumentAsNumber(runtime, arguments, count, 2)) + : para::PlaceholderAlignment::kBaseline; + auto baseline = count >= 4 + ? static_cast( + getArgumentAsNumber(runtime, arguments, count, 3)) + : para::TextBaseline::kAlphabetic; + auto offset = + count >= 5 ? getArgumentAsNumber(runtime, arguments, count, 4) : 0; + + para::PlaceholderStyle style(width, height, alignment, baseline, offset); + _builder->addPlaceholder(style); + + return thisValue.asObject(runtime); + } + + JSI_HOST_FUNCTION(pushStyle) { + auto textStyle = JsiSkTextStyle::fromValue(runtime, arguments[0]); + // Foreground paint + if (count >= 2) { + auto foreground = + tryGetArgumentAsHostObject(runtime, arguments, count, 1); + if (foreground) { + textStyle.setForegroundPaint(*foreground->getObject().get()); + } + } + // Background paint + if (count >= 3) { + auto background = + tryGetArgumentAsHostObject(runtime, arguments, count, 2); + if (background) { + textStyle.setBackgroundPaint(*background->getObject().get()); + } + } + + _builder->pushStyle(textStyle); + + return thisValue.asObject(runtime); + } + + JSI_HOST_FUNCTION(pop) { + _builder->pop(); + return thisValue.asObject(runtime); + } + + JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiSkParagraphBuilder, build), + JSI_EXPORT_FUNC(JsiSkParagraphBuilder, reset), + JSI_EXPORT_FUNC(JsiSkParagraphBuilder, addText), + JSI_EXPORT_FUNC(JsiSkParagraphBuilder, addPlaceholder), + JSI_EXPORT_FUNC(JsiSkParagraphBuilder, pushStyle), + JSI_EXPORT_FUNC(JsiSkParagraphBuilder, pop)) + + explicit JsiSkParagraphBuilder(std::shared_ptr context, + para::ParagraphStyle paragraphStyle, + sk_sp fontManager) + : JsiSkHostObject(std::move(context)) { + _fontCollection = sk_make_sp(); + _fontCollection->setDefaultFontManager(getContext()->createFontMgr()); + if (fontManager != nullptr) { + _fontCollection->setAssetFontManager(fontManager); + } + _fontCollection->enableFontFallback(); + _builder = para::ParagraphBuilder::make(paragraphStyle, _fontCollection); + } + +private: + std::unique_ptr _builder; + sk_sp _fontCollection; +}; + +/** + Implementation of the ParagraphBuilderFactory for making ParagraphBuilder JSI + object + */ +class JsiSkParagraphBuilderFactory : public JsiSkHostObject { +public: + JSI_HOST_FUNCTION(Make) { + // Get paragraph style from params + auto paragraphStyle = + count > 0 ? JsiSkParagraphStyle::fromValue(runtime, arguments[0]) + : para::ParagraphStyle(); + + // get font manager + auto fontMgr = + count > 1 ? JsiSkTypefaceFontProvider::fromValue(runtime, arguments[1]) + : nullptr; + + // Create the paragraph builder + return jsi::Object::createFromHostObject( + runtime, std::make_shared( + getContext(), paragraphStyle, fontMgr)); + } + + JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiSkParagraphBuilderFactory, Make)) + + explicit JsiSkParagraphBuilderFactory( + std::shared_ptr context) + : JsiSkHostObject(std::move(context)) {} +}; + +} // namespace RNSkia diff --git a/package/cpp/api/JsiSkParagraphStyle.h b/package/cpp/api/JsiSkParagraphStyle.h new file mode 100644 index 0000000000..c58ca0a2fb --- /dev/null +++ b/package/cpp/api/JsiSkParagraphStyle.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" + +#include "Paragraph.h" + +#pragma clang diagnostic pop + +namespace RNSkia { + +namespace jsi = facebook::jsi; + +namespace para = skia::textlayout; +/** + Implementation of the ParagraphStyle object in JSI + */ +class JsiSkParagraphStyle { +public: + /** + disableHinting?: boolean; + ellipsis?: string; + heightMultiplier?: number; + maxLines?: number; + replaceTabCharacters?: boolean; + strutStyle?: SkStrutStyle; + textAlign?: SkTextAlign; + textDirection?: SkTextDirection; + textHeightBehavior?: SkTextHeightBehavior; + textStyle?: SkTextStyle; + */ + static para::ParagraphStyle fromValue(jsi::Runtime &runtime, + const jsi::Value &value) { + para::ParagraphStyle retVal; + + // Accept undefined && null + if (value.isUndefined() || value.isNull()) { + return retVal; + } + + // Read values from the argument - expected to be a ParagraphStyle shaped + // object + if (!value.isObject()) { + throw jsi::JSError(runtime, "Expected SkParagrahStyle as first argument"); + } + + auto object = value.asObject(runtime); + + if (object.hasProperty(runtime, "disableHinting")) { + auto propValue = object.getProperty(runtime, "disableHinting"); + if (propValue.asBool()) { + retVal.turnHintingOff(); + } + } + if (object.hasProperty(runtime, "ellipsis")) { + auto propValue = object.getProperty(runtime, "ellipsis"); + auto inStr = propValue.asString(runtime).utf8(runtime); + std::u16string uStr; + fromUTF8(inStr, uStr); + retVal.setEllipsis(uStr); + } + if (object.hasProperty(runtime, "heightMultiplier")) { + auto propValue = object.getProperty(runtime, "heightMultiplier"); + retVal.setHeight(propValue.asNumber()); + } + if (object.hasProperty(runtime, "maxLines")) { + auto propValue = object.getProperty(runtime, "maxLines"); + if (propValue.asNumber() != 0) { + retVal.setMaxLines(propValue.asNumber()); + } + } + if (object.hasProperty(runtime, "replaceTabCharacters")) { + auto propValue = object.getProperty(runtime, "replaceTabCharacters"); + retVal.setReplaceTabCharacters(propValue.asBool()); + } + if (object.hasProperty(runtime, "textAlign")) { + auto propValue = object.getProperty(runtime, "textAlign"); + retVal.setTextAlign(static_cast(propValue.asNumber())); + } + if (object.hasProperty(runtime, "textDirection")) { + auto propValue = object.getProperty(runtime, "textDirection"); + retVal.setTextDirection( + static_cast(propValue.asNumber())); + } + if (object.hasProperty(runtime, "textHeightBehavior")) { + auto propValue = object.getProperty(runtime, "textHeightBehavior"); + retVal.setTextHeightBehavior( + static_cast(propValue.asNumber())); + } + if (object.hasProperty(runtime, "strutStyle")) { + auto propValue = object.getProperty(runtime, "strutStyle"); + retVal.setStrutStyle(JsiSkStrutStyle::fromValue(runtime, propValue)); + } + if (object.hasProperty(runtime, "textStyle")) { + auto propValue = object.getProperty(runtime, "textStyle"); + retVal.setTextStyle(JsiSkTextStyle::fromValue(runtime, propValue)); + } + + return retVal; + } + +private: + template + static void fromUTF8( + const std::string &source, + std::basic_string, std::allocator> &result) { + std::wstring_convert, T> convertor; + result = convertor.from_bytes(source); + } +}; + +} // namespace RNSkia diff --git a/package/cpp/api/JsiSkStrutStyle.h b/package/cpp/api/JsiSkStrutStyle.h new file mode 100644 index 0000000000..dbcc70fae7 --- /dev/null +++ b/package/cpp/api/JsiSkStrutStyle.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" + +#include "Paragraph.h" + +#pragma clang diagnostic pop + +namespace RNSkia { + +namespace jsi = facebook::jsi; + +namespace para = skia::textlayout; + +/** + Implementation of the TextStyle object in JSI for the paragraph builder + */ +class JsiSkStrutStyle { +public: + static para::StrutStyle fromValue(jsi::Runtime &runtime, + const jsi::Value &value) { + // Read values from the argument - expected to be a TextStyle shaped object + if (!value.isObject()) { + throw jsi::JSError(runtime, "Expected SkStrutStyle as first argument"); + } + /** + strutEnabled?: boolean; + fontFamilies?: string[]; + fontStyle?: SkTextFontStyle; + fontSize?: number; + heightMultiplier?: number; + halfLeading?: boolean; + leading?: number; + forceStrutHeight?: boolean; + */ + auto object = value.asObject(runtime); + + para::StrutStyle retVal; + + if (object.hasProperty(runtime, "strutEnabled")) { + auto propValue = object.getProperty(runtime, "strutEnabled"); + retVal.setStrutEnabled(propValue.asBool()); + } + if (object.hasProperty(runtime, "fontFamilies")) { + auto propValue = object.getProperty(runtime, "fontFamilies") + .asObject(runtime) + .asArray(runtime); + auto size = propValue.size(runtime); + std::vector families(size); + for (size_t i = 0; i < size; ++i) { + families[i] = propValue.getValueAtIndex(runtime, i) + .asString(runtime) + .utf8(runtime) + .c_str(); + } + } + + if (object.hasProperty(runtime, "fontStyle")) { + auto propValue = object.getProperty(runtime, "fontStyle"); + retVal.setFontStyle(*JsiSkFontStyle::fromValue(runtime, propValue).get()); + } + if (object.hasProperty(runtime, "fontSize")) { + auto propValue = object.getProperty(runtime, "fontSize"); + retVal.setFontSize(propValue.asNumber()); + } + if (object.hasProperty(runtime, "heightMultiplier")) { + auto propValue = object.getProperty(runtime, "heightMultiplier"); + retVal.setHeight(propValue.asNumber()); + retVal.setHeightOverride(true); + } + if (object.hasProperty(runtime, "halfLeading")) { + auto propValue = object.getProperty(runtime, "halfLeading"); + retVal.setHalfLeading(propValue.asBool()); + } + if (object.hasProperty(runtime, "leading")) { + auto propValue = object.getProperty(runtime, "leading"); + retVal.setLeading(propValue.asNumber()); + } + if (object.hasProperty(runtime, "forceStrutHeight")) { + auto propValue = object.getProperty(runtime, "forceStrutHeight"); + retVal.setForceStrutHeight(propValue.asBool()); + } + + return retVal; + } +}; + +} // namespace RNSkia diff --git a/package/cpp/api/JsiSkTextStyle.h b/package/cpp/api/JsiSkTextStyle.h new file mode 100644 index 0000000000..211259c662 --- /dev/null +++ b/package/cpp/api/JsiSkTextStyle.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" + +#include "Paragraph.h" +#include "ParagraphStyle.h" + +#pragma clang diagnostic pop + +namespace RNSkia { + +namespace jsi = facebook::jsi; + +namespace para = skia::textlayout; + +/** + Implementation of the TextStyle object in JSI for the paragraph builder + */ +class JsiSkTextStyle { +public: + static para::TextStyle fromValue(jsi::Runtime &runtime, + const jsi::Value &value) { + + para::TextStyle retVal; + + // Accept undefined && null + if (value.isUndefined() || value.isNull()) { + return retVal; + } + + // Read values from the argument - expected to be a TextStyle shaped object + if (!value.isObject()) { + throw jsi::JSError(runtime, "Expected SkTextStyle as first argument"); + } + + auto object = value.asObject(runtime); + + if (object.hasProperty(runtime, "backgroundColor")) { + auto propValue = object.getProperty(runtime, "backgroundColor"); + SkPaint p; + p.setColor(JsiSkColor::fromValue(runtime, propValue)); + retVal.setBackgroundPaint(p); + } + if (object.hasProperty(runtime, "color")) { + auto propValue = object.getProperty(runtime, "color"); + retVal.setColor(JsiSkColor::fromValue(runtime, propValue)); + } + if (object.hasProperty(runtime, "decoration")) { + auto propValue = object.getProperty(runtime, "decoration"); + retVal.setDecoration( + static_cast(propValue.asNumber())); + } + if (object.hasProperty(runtime, "decorationColor")) { + auto propValue = object.getProperty(runtime, "decorationColor"); + retVal.setDecorationColor(JsiSkColor::fromValue(runtime, propValue)); + } + if (object.hasProperty(runtime, "decorationThickness")) { + auto propValue = object.getProperty(runtime, "decorationThickness"); + retVal.setDecorationThicknessMultiplier(propValue.asNumber()); + } + if (object.hasProperty(runtime, "decorationStyle")) { + auto propValue = object.getProperty(runtime, "decorationStyle"); + retVal.setDecorationStyle( + static_cast(propValue.asNumber())); + } + if (object.hasProperty(runtime, "fontFamilies")) { + auto propValue = object.getProperty(runtime, "fontFamilies") + .asObject(runtime) + .asArray(runtime); + auto size = propValue.size(runtime); + std::vector families(size); + for (size_t i = 0; i < size; ++i) { + families[i] = propValue.getValueAtIndex(runtime, i) + .asString(runtime) + .utf8(runtime) + .c_str(); + } + retVal.setFontFamilies(families); + } + if (object.hasProperty(runtime, "fontFeatures")) { + auto propValue = object.getProperty(runtime, "fontFeatures") + .asObject(runtime) + .asArray(runtime); + auto size = propValue.size(runtime); + retVal.resetFontFeatures(); + for (size_t i = 0; i < size; ++i) { + auto element = propValue.getValueAtIndex(runtime, i).asObject(runtime); + auto name = element.getProperty(runtime, "name") + .asString(runtime) + .utf8(runtime); + auto value = element.getProperty(runtime, "value").asNumber(); + retVal.addFontFeature(SkString(name), value); + } + } + if (object.hasProperty(runtime, "fontSize")) { + auto propValue = object.getProperty(runtime, "fontSize"); + retVal.setFontSize(propValue.asNumber()); + } + if (object.hasProperty(runtime, "fontStyle")) { + auto propValue = + object.getProperty(runtime, "fontStyle").asObject(runtime); + auto weight = static_cast( + propValue.hasProperty(runtime, "weight") + ? propValue.getProperty(runtime, "weight").asNumber() + : SkFontStyle::Weight::kNormal_Weight); + + auto width = static_cast( + propValue.hasProperty(runtime, "width") + ? propValue.getProperty(runtime, "width").asNumber() + : SkFontStyle::Width::kNormal_Width); + + auto slant = static_cast( + propValue.hasProperty(runtime, "slant") + ? propValue.getProperty(runtime, "slant").asNumber() + : SkFontStyle::Slant::kUpright_Slant); + + retVal.setFontStyle(SkFontStyle(weight, width, slant)); + } + if (object.hasProperty(runtime, "foregroundColor")) { + auto propValue = object.getProperty(runtime, "foregroundColor"); + SkPaint p; + p.setColor(JsiSkColor::fromValue(runtime, propValue)); + retVal.setForegroundColor(p); + } + if (object.hasProperty(runtime, "heightMultiplier")) { + auto propValue = object.getProperty(runtime, "heightMultiplier"); + retVal.setHeight(propValue.asNumber()); + retVal.setHeightOverride(true); + } + if (object.hasProperty(runtime, "halfLeading")) { + auto propValue = object.getProperty(runtime, "halfLeading"); + retVal.setHalfLeading(propValue.asNumber()); + } + if (object.hasProperty(runtime, "letterSpacing")) { + auto propValue = object.getProperty(runtime, "letterSpacing"); + retVal.setLetterSpacing(propValue.asNumber()); + } + if (object.hasProperty(runtime, "locale")) { + auto propValue = object.getProperty(runtime, "locale"); + retVal.setLocale(SkString(propValue.asString(runtime).utf8(runtime))); + } + if (object.hasProperty(runtime, "shadows")) { + auto propValue = object.getProperty(runtime, "shadows") + .asObject(runtime) + .asArray(runtime); + auto size = propValue.size(runtime); + retVal.resetShadows(); + for (size_t i = 0; i < size; ++i) { + auto element = propValue.getValueAtIndex(runtime, i).asObject(runtime); + auto color = element.hasProperty(runtime, "color") + ? JsiSkColor::fromValue( + runtime, element.getProperty(runtime, "color")) + : SK_ColorBLACK; + SkPoint offset = + element.hasProperty(runtime, "offset") + ? *JsiSkPoint::fromValue(runtime, + element.getProperty(runtime, "offset")) + .get() + : SkPoint::Make(0, 0); + auto blurSigma = + element.hasProperty(runtime, "blurRadius") + ? element.getProperty(runtime, "blurRadius").asNumber() + : 0; + retVal.addShadow(para::TextShadow(color, offset, blurSigma)); + } + } + if (object.hasProperty(runtime, "textBaseline")) { + auto propValue = object.getProperty(runtime, "textBaseline"); + retVal.setTextBaseline( + static_cast(propValue.asNumber())); + } + + return retVal; + } +}; + +} // namespace RNSkia diff --git a/package/cpp/jsi/JsiHostObject.h b/package/cpp/jsi/JsiHostObject.h index 79c1fe4244..cb91af8be5 100644 --- a/package/cpp/jsi/JsiHostObject.h +++ b/package/cpp/jsi/JsiHostObject.h @@ -331,6 +331,27 @@ class JsiHostObject : public jsi::HostObject { return value.asHostObject(runtime); } + /** + Returns argument as host object or nullptr if the value is not a valid host + object of requested type + */ + template + static std::shared_ptr + tryGetArgumentAsHostObject(jsi::Runtime &runtime, const jsi::Value *arguments, + size_t count, size_t index) { + const jsi::Value &value = getArgument(runtime, arguments, count, index); + if (!value.isObject()) { + return nullptr; + } + + auto object = value.asObject(runtime); + if (!object.isHostObject(runtime)) { + return nullptr; + } + + return object.asHostObject(runtime); + } + /** Returns argument as array or throws */ diff --git a/package/cpp/rnskia/dom/JsiDomApi.h b/package/cpp/rnskia/dom/JsiDomApi.h index 8a84498b06..0f6ff332ca 100644 --- a/package/cpp/rnskia/dom/JsiDomApi.h +++ b/package/cpp/rnskia/dom/JsiDomApi.h @@ -48,6 +48,7 @@ #include "nodes/JsiTextPathNode.h" #include "nodes/JsiLayerNode.h" +#include "nodes/JsiParagraphNode.h" namespace RNSkia { @@ -162,6 +163,9 @@ class JsiDomApi : public JsiHostObject { installFunction("TextBlobNode", JsiTextBlobNode::createCtor(context)); installFunction("LayerNode", JsiLayerNode::createCtor(context)); + + // Paragraph node + installFunction("ParagraphNode", JsiParagraphNode::createCtor(context)); } }; diff --git a/package/cpp/rnskia/dom/nodes/JsiParagraphNode.h b/package/cpp/rnskia/dom/nodes/JsiParagraphNode.h new file mode 100644 index 0000000000..b0d931069e --- /dev/null +++ b/package/cpp/rnskia/dom/nodes/JsiParagraphNode.h @@ -0,0 +1,62 @@ +#pragma once + +#include "JsiDomDrawingNode.h" +#include "ParagraphProp.h" + +#include + +namespace RNSkia { + +class JsiParagraphNode : public JsiDomDrawingNode, + public JsiDomNodeCtor { +public: + explicit JsiParagraphNode(std::shared_ptr context) + : JsiDomDrawingNode(context, "skParagraph") {} + +protected: + void draw(DrawingContext *context) override { + auto x = _xProp->value().getAsNumber(); + auto y = _yProp->value().getAsNumber(); + auto width = static_cast(_widthProp->value().getAsNumber()); + + auto p = *_paragraphProp->getDerivedValue(); + if (p != nullptr) { + + // Let's ensure that we don't perform unnecessary layouts on the + // paragraph. We should only layout if we have a new paragraph or if the + // layout width has changed. + if (_lastLayoutWidth != width || _lastLayoutParagraph != p) { + // perform layout! + p->layout(width); + _lastLayoutWidth = width; + _lastLayoutParagraph = p; + } + // Paint the layout to the canvas + p->paint(context->getCanvas(), x, y); + } + } + + void defineProperties(NodePropsContainer *container) override { + JsiDomDrawingNode::defineProperties(container); + _paragraphProp = container->defineProperty("paragraph"); + + _xProp = container->defineProperty("x"); + _xProp->require(); + + _yProp = container->defineProperty("y"); + _yProp->require(); + + _widthProp = container->defineProperty("width"); + _widthProp->require(); + } + +private: + ParagraphProp *_paragraphProp; + NodeProp *_xProp; + NodeProp *_yProp; + NodeProp *_widthProp; + SkScalar _lastLayoutWidth; + para::Paragraph *_lastLayoutParagraph = nullptr; +}; + +} // namespace RNSkia diff --git a/package/cpp/rnskia/dom/props/ParagraphProp.h b/package/cpp/rnskia/dom/props/ParagraphProp.h new file mode 100644 index 0000000000..01c1ecd9fb --- /dev/null +++ b/package/cpp/rnskia/dom/props/ParagraphProp.h @@ -0,0 +1,45 @@ +#pragma once + +#include "DerivedNodeProp.h" + +#include "JsiSkParagraph.h" + +#include +#include + +namespace RNSkia { + +class ParagraphProp : public DerivedProp { +public: + explicit ParagraphProp(PropId name, + const std::function &onChange) + : DerivedProp(onChange) { + _paragraphProp = defineProperty(name); + } + + void updateDerivedValue() override { + if (_paragraphProp->isSet()) { + if (_paragraphProp->value().getType() != PropType::HostObject) { + throw std::runtime_error("Expected Paragraph object for the " + + std::string(getName()) + " property."); + } + + auto ptr = std::dynamic_pointer_cast( + _paragraphProp->value().getAsHostObject()); + + if (ptr == nullptr) { + throw std::runtime_error("Expected paragraph object for the " + + std::string(getName()) + " property."); + } + + setDerivedValue(ptr->getParagraph()); + } else { + setDerivedValue(nullptr); + } + } + +private: + NodeProp *_paragraphProp; +}; + +} // namespace RNSkia diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-android.png new file mode 100644 index 0000000000..6719f98622 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-ios.png new file mode 100644 index 0000000000..0581b27557 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-auto-linebreaks-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-android.png new file mode 100644 index 0000000000..d719e389ad Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-ios.png new file mode 100644 index 0000000000..6094f84a97 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-ellipse-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-android.png new file mode 100644 index 0000000000..09df083628 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-ios.png new file mode 100644 index 0000000000..73d83c0490 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-linebreaks-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-android.png new file mode 100644 index 0000000000..cd7a47883e Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-ios.png new file mode 100644 index 0000000000..b66423d4c1 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-center-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-android.png new file mode 100644 index 0000000000..a8c14fc96d Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-ios.png new file mode 100644 index 0000000000..5654ca0df6 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-justify-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-android.png new file mode 100644 index 0000000000..ce74a3c119 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-ios.png new file mode 100644 index 0000000000..3a5ba35dfa Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-left-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-android.png new file mode 100644 index 0000000000..a7773c654d Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-ios.png new file mode 100644 index 0000000000..c321b261b4 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-right-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-android.png new file mode 100644 index 0000000000..96ba65190e Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-ios.png new file mode 100644 index 0000000000..3d90bca37e Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-align-rtl-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-android.png new file mode 100644 index 0000000000..0fcdf51eb7 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-ios.png new file mode 100644 index 0000000000..d7449778c0 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-colors-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-android.png new file mode 100644 index 0000000000..824d49cc77 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-ios.png new file mode 100644 index 0000000000..e30f8c8d84 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-decoration-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-android.png new file mode 100644 index 0000000000..892a4ed95c Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-ios.png new file mode 100644 index 0000000000..dd57e8b781 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-shadow-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-android.png new file mode 100644 index 0000000000..0166530665 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-ios.png new file mode 100644 index 0000000000..2d4d05b8b6 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-font-style-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-android.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-android.png new file mode 100644 index 0000000000..4e93f01832 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-ios.png b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-ios.png new file mode 100644 index 0000000000..94da464e3f Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/paragraph-text-style-in-paragraph-style-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/simple-paragraph-android.png b/package/src/__tests__/snapshots/paragraph/simple-paragraph-android.png new file mode 100644 index 0000000000..cac3fc276e Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/simple-paragraph-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/simple-paragraph-ios.png b/package/src/__tests__/snapshots/paragraph/simple-paragraph-ios.png new file mode 100644 index 0000000000..8a358d504a Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/simple-paragraph-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-android.png b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-android.png new file mode 100644 index 0000000000..57cce1e009 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-android.png differ diff --git a/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-ios.png b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-ios.png new file mode 100644 index 0000000000..47e8ee60cd Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-ios.png differ diff --git a/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-node.png b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-node.png new file mode 100644 index 0000000000..9c96fcd1d4 Binary files /dev/null and b/package/src/__tests__/snapshots/paragraph/simple-paragraph-with-provider-node.png differ diff --git a/package/src/dom/nodes/JsiSkDOM.ts b/package/src/dom/nodes/JsiSkDOM.ts index 90bade884c..d83c3981d6 100644 --- a/package/src/dom/nodes/JsiSkDOM.ts +++ b/package/src/dom/nodes/JsiSkDOM.ts @@ -57,6 +57,7 @@ import type { Path2DPathEffectProps, } from "../types/PathEffects"; import { NATIVE_DOM } from "../../renderer/HostComponents"; +import type { ParagraphProps } from "../types/Paragraph"; import { FillNode, @@ -123,6 +124,7 @@ import { GroupNode } from "./GroupNode"; import { PaintNode } from "./PaintNode"; import type { NodeContext } from "./Node"; import { LayerNode } from "./LayerNode"; +import { ParagraphNode } from "./drawings/ParagraphNode"; export class JsiSkDOM implements SkDOM { constructor(private ctx: NodeContext) {} @@ -470,4 +472,11 @@ export class JsiSkDOM implements SkDOM { ? global.SkiaDomApi.BoxShadowNode(props) : new BoxShadowNode(this.ctx, props); } + + // Paragraph + Paragraph(props: ParagraphProps) { + return NATIVE_DOM + ? global.SkiaDomApi.ParagraphNode(props) + : new ParagraphNode(this.ctx, props); + } } diff --git a/package/src/dom/nodes/drawings/ParagraphNode.ts b/package/src/dom/nodes/drawings/ParagraphNode.ts new file mode 100644 index 0000000000..7a150b3d77 --- /dev/null +++ b/package/src/dom/nodes/drawings/ParagraphNode.ts @@ -0,0 +1,22 @@ +import type { DrawingContext, ParagraphProps } from "../../types"; +import { NodeType } from "../../types"; +import { JsiDrawingNode } from "../DrawingNode"; +import type { NodeContext } from "../Node"; + +export class ParagraphNode extends JsiDrawingNode { + constructor(ctx: NodeContext, props: ParagraphProps) { + super(ctx, NodeType.Paragraph, props); + } + + deriveProps() { + return null; + } + + draw({ canvas }: DrawingContext) { + const { paragraph, x, y, width } = this.props; + if (paragraph) { + paragraph.layout(width); + paragraph.paint(canvas, x, y); + } + } +} diff --git a/package/src/dom/types/NodeType.ts b/package/src/dom/types/NodeType.ts index 0b5295aec2..9b600e8e35 100644 --- a/package/src/dom/types/NodeType.ts +++ b/package/src/dom/types/NodeType.ts @@ -68,6 +68,9 @@ export const enum NodeType { Glyphs = "skGlyphs", Picture = "skPicture", ImageSVG = "skImageSVG", + + // Paragraph + Paragraph = "skParagraph", } export const enum DeclarationType { diff --git a/package/src/dom/types/Paragraph.ts b/package/src/dom/types/Paragraph.ts new file mode 100644 index 0000000000..dda79e9724 --- /dev/null +++ b/package/src/dom/types/Paragraph.ts @@ -0,0 +1,10 @@ +import type { SkParagraph } from "../../skia/types/Paragraph"; + +import type { GroupProps } from "./Common"; + +export interface ParagraphProps extends GroupProps { + paragraph: SkParagraph | null; + x: number; + y: number; + width: number; +} diff --git a/package/src/dom/types/SkDOM.ts b/package/src/dom/types/SkDOM.ts index 6b856c894d..05c7a6c920 100644 --- a/package/src/dom/types/SkDOM.ts +++ b/package/src/dom/types/SkDOM.ts @@ -58,6 +58,7 @@ import type { Path1DPathEffectProps, Path2DPathEffectProps, } from "./PathEffects"; +import type { ParagraphProps } from "./Paragraph"; type ImageFilterNode

= DeclarationNode

; @@ -179,4 +180,7 @@ export interface SkDOM { BackdropFilter(props: ChildrenProps): RenderNode; Box(props: BoxProps): RenderNode; BoxShadow(props: BoxShadowProps): DeclarationNode; + + // Paragraph + Paragraph(props: ParagraphProps): RenderNode; } diff --git a/package/src/dom/types/index.ts b/package/src/dom/types/index.ts index 813e605580..6a0b27eb30 100644 --- a/package/src/dom/types/index.ts +++ b/package/src/dom/types/index.ts @@ -10,3 +10,4 @@ export * from "./ColorFilters"; export * from "./MaskFilters"; export * from "./PathEffects"; export * from "./Shaders"; +export * from "./Paragraph"; diff --git a/package/src/renderer/HostComponents.ts b/package/src/renderer/HostComponents.ts index d9d05ce3fb..63381d751e 100644 --- a/package/src/renderer/HostComponents.ts +++ b/package/src/renderer/HostComponents.ts @@ -50,6 +50,7 @@ import type { LerpColorFilterProps, BoxProps, BoxShadowProps, + ParagraphProps, } from "../dom/types"; import type { ChildrenProps } from "../dom/types/Common"; import type { @@ -193,6 +194,9 @@ declare global { BoxNode: (prop: BoxProps) => RenderNode; BoxShadowNode: (prop: BoxShadowProps) => DeclarationNode; LayerNode: (prop: ChildrenProps) => RenderNode; + + // Paragraph + ParagraphNode: (props: ParagraphProps) => RenderNode; }; // eslint-disable-next-line @typescript-eslint/no-namespace @@ -268,6 +272,9 @@ declare global { skBackdropFilter: SkiaProps; skBox: SkiaProps; skBoxShadow: SkiaProps; + + // Paragraph + skParagraph: SkiaProps; } } } @@ -399,6 +406,9 @@ export const createNode = ( return Sk.Box(props); case NodeType.BoxShadow: return Sk.BoxShadow(props); + // Paragraph + case NodeType.Paragraph: + return Sk.Paragraph(props); default: return exhaustiveCheck(type); } diff --git a/package/src/renderer/__tests__/e2e/Paragraphs.spec.tsx b/package/src/renderer/__tests__/e2e/Paragraphs.spec.tsx new file mode 100644 index 0000000000..f44b2de112 --- /dev/null +++ b/package/src/renderer/__tests__/e2e/Paragraphs.spec.tsx @@ -0,0 +1,441 @@ +import { resolveFile, surface } from "../setup"; +import { + CI, + checkImage, + docPath, + itRunsE2eOnly, +} from "../../../__tests__/setup"; +import { + FontStyle, + SkTextAlign, + SkTextDirection, + TextDecoration, +} from "../../../skia/types"; + +const Pacifico = Array.from( + resolveFile("skia/__tests__/assets/Pacifico-Regular.ttf") +); + +const RobotoMedium = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Medium.ttf") +); + +const RobotoRegular = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Regular.ttf") +); + +const RobotoBold = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Bold.ttf") +); + +const RobotoItalic = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Italic.ttf") +); + +const Noto = Array.from( + resolveFile("skia/__tests__/assets/NotoColorEmoji.ttf") +); + +describe("Paragraphs", () => { + it("Should build the first example from the documentation", async () => { + const img = await surface.drawParagraph( + (Skia, ctx) => { + const robotoMedium = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoMedium)) + )!; + const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(robotoMedium, "Roboto"); + provider.registerFont(robotoRegular, "Roboto"); + if (ctx.OS === "node") { + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.Noto)) + )!; + provider.registerFont(noto, "Noto"); + } + const textStyle = { + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto"], + fontSize: 50, + }; + return Skia.ParagraphBuilder.Make({}, provider) + .pushStyle(textStyle) + .addText("Say Hello to ") + .pushStyle({ ...textStyle, fontStyle: { weight: 500 } }) + .addText("Skia 🎨") + .pop() + .build(); + }, + surface.width, + { + RobotoRegular, + RobotoMedium, + Noto: surface.OS === "node" ? Noto : [], + OS: surface.OS, + } + ); + checkImage(img, docPath(`paragraph/hello-world-${surface.OS}.png`), { + // In CI, the emoji font is different + maxPixelDiff: CI ? 15000 : 200, + }); + }); + it("Should build the example from the documentation with text styles", async () => { + const img = await surface.drawParagraph( + (Skia, ctx) => { + const robotoBold = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoBold)) + )!; + const robotoRegular = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const robotoItalic = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoItalic)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(robotoBold, "Roboto"); + provider.registerFont(robotoRegular, "Roboto"); + provider.registerFont(robotoItalic, "Roboto"); + const textStyle = { + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }; + return Skia.ParagraphBuilder.Make({}, provider) + .pushStyle({ ...textStyle, fontStyle: ctx.Bold }) + .addText("This text is bold\n") + .pop() + .pushStyle({ ...textStyle, fontStyle: ctx.Normal }) + .addText("This text is regular\n") + .pop() + .pushStyle({ ...textStyle, fontStyle: ctx.Italic }) + .addText("This text is italic") + .pop() + .build(); + }, + surface.width, + { + RobotoRegular, + RobotoBold, + RobotoItalic, + Bold: FontStyle.Bold, + Normal: FontStyle.Normal, + Italic: FontStyle.Italic, + } + ); + checkImage(img, docPath(`paragraph/font-style-${surface.OS}.png`)); + }); + itRunsE2eOnly("should render simple paragraph", async () => { + const img = await surface.drawParagraph((Skia) => { + return Skia.ParagraphBuilder.Make() + .pushStyle({ color: Skia.Color("black") }) + .addText("Hello from Skia!") + .build(); + }); + checkImage(img, `snapshots/paragraph/simple-paragraph-${surface.OS}.png`); + }); + it("should render simple paragraph with custom font", async () => { + const img = await surface.drawParagraph( + (Skia, ctx) => { + const tf = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.Pacifico)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(tf, "Pacifico"); + return Skia.ParagraphBuilder.Make({}, provider) + .pushStyle({ + fontFamilies: ["Pacifico"], + fontSize: 50, + color: Skia.Color("blakc"), + }) + .addText("Hello from Skia!") + .build(); + }, + surface.width, + { Pacifico } + ); + checkImage( + img, + `snapshots/paragraph/simple-paragraph-with-provider-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should render paragraph linebreaks", async () => { + const img = await surface.drawParagraph((Skia) => + Skia.ParagraphBuilder.Make() + .pushStyle({ color: Skia.Color("black") }) + .addText("Hello\nfrom Skia") + .build() + ); + checkImage( + img, + `snapshots/paragraph/paragraph-linebreaks-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should break when line is long", async () => { + const img = await surface.drawParagraph( + (Skia) => + Skia.ParagraphBuilder.Make() + .pushStyle({ color: Skia.Color("black") }) + .addText("Hello from a really, really long line - and from Skia!") + .build(), + 50 + ); + checkImage( + img, + `snapshots/paragraph/paragraph-auto-linebreaks-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should align text to the right", async () => { + const img = await surface.drawParagraph( + (Skia, { textAlign }) => + Skia.ParagraphBuilder.Make({ + textAlign, + }) + .pushStyle({ color: Skia.Color("black") }) + .addText("Hello Skia!") + .build(), + surface.width, + { textAlign: SkTextAlign.Right } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-align-right-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should align text centered", async () => { + const img = await surface.drawParagraph( + (Skia, { textAlign }) => + Skia.ParagraphBuilder.Make({ + textAlign, + }) + .pushStyle({ color: Skia.Color("black") }) + .addText("Hello Skia!") + .build(), + surface.width, + { textAlign: SkTextAlign.Center } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-align-center-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should align text justified", async () => { + const img = await surface.drawParagraph( + (Skia, { textAlign }) => + Skia.ParagraphBuilder.Make({ + textAlign, + textStyle: { + color: Skia.Color("black"), + }, + }) + .addText( + "Hello Skia this text should be justified - what do you think? Is it justified?" + ) + .build(), + surface.width, + { textAlign: SkTextAlign.Justify } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-align-justify-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should align text left", async () => { + const img = await surface.drawParagraph( + (Skia) => + Skia.ParagraphBuilder.Make({ + textStyle: { + color: Skia.Color("black"), + }, + }) + .addText( + "Hello Skia this text should be justified - what do you think? Is it justified?" + ) + .build(), + surface.width + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-align-left-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should render text right to left", async () => { + const img = await surface.drawParagraph( + (Skia, { textDirection }) => + Skia.ParagraphBuilder.Make({ + textDirection, + textStyle: { + color: Skia.Color("black"), + }, + }) + .addText("Hello Skia RTL\nThis is a new line") + .build(), + 150, + { textDirection: SkTextDirection.RTL } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-align-rtl-${surface.OS}.png` + ); + }); + + itRunsE2eOnly( + "should show ellipse when line count is above max lines", + async () => { + const img = await surface.drawParagraph( + (Skia, { maxLines, ellipsis }) => + Skia.ParagraphBuilder.Make({ + maxLines, + ellipsis, + textStyle: { + color: Skia.Color("black"), + }, + }) + .addText("Hello Skia - maxLine is 1!") + .build(), + 50, + { maxLines: 1, ellipsis: "..." } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-ellipse-${surface.OS}.png` + ); + } + ); + + itRunsE2eOnly("should use textstyle in paraphstyle", async () => { + const img = await surface.drawParagraph( + (Skia) => + Skia.ParagraphBuilder.Make() + .pushStyle({ color: Skia.Color("red") }) + .addText("Hello Skia!") + .build(), + 50 + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-style-in-paragraph-style-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should support colors", async () => { + const img = await surface.drawParagraph( + (Skia) => + Skia.ParagraphBuilder.Make() + .pushStyle({ color: Skia.Color("red") }) + .addText("Hello Skia in red color") + .pop() + .pushStyle({ backgroundColor: Skia.Color("blue") }) + .addText("Hello Skia in blue backgroundcolor") + .pop() + .pushStyle({ foregroundColor: Skia.Color("yellow") }) + .addText("Hello Skia with yellow foregroundcolor") + .pop() + .build(), + 150 + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-style-colors-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should support text decoration", async () => { + const img = await surface.drawParagraph( + (Skia, { Overline, LineThrough, Underline }) => + Skia.ParagraphBuilder.Make() + .pushStyle({ + decoration: Underline, + decorationColor: Skia.Color("blue"), + color: Skia.Color("black"), + }) + .addText("Hello Skia with blue underline") + .pop() + .pushStyle({ + decoration: LineThrough, + decorationColor: Skia.Color("red"), + color: Skia.Color("black"), + }) + .addText("Hello Skia with red strike-through") + .pop() + .pushStyle({ + decoration: Overline, + decorationColor: Skia.Color("green"), + color: Skia.Color("black"), + }) + .addText("Hello Skia with green overline") + .pop() + .build(), + 150, + { + Overline: TextDecoration.Overline, + LineThrough: TextDecoration.LineThrough, + Underline: TextDecoration.Underline, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-style-decoration-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should support font styling", async () => { + const img = await surface.drawParagraph( + (Skia, { Italic, Bold, BoldItalic }) => + Skia.ParagraphBuilder.Make() + .pushStyle({ fontStyle: Italic, color: Skia.Color("black") }) + .addText("Hello Skia in italic") + .pop() + .pushStyle({ fontStyle: Bold, color: Skia.Color("black") }) + .addText("Hello Skia in bold") + .pop() + .pushStyle({ fontStyle: BoldItalic, color: Skia.Color("black") }) + .addText("Hello Skia in bold-italic") + .pop() + .build(), + 150, + { + Italic: FontStyle.Italic, + Bold: FontStyle.Bold, + BoldItalic: FontStyle.BoldItalic, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-style-font-style-${surface.OS}.png` + ); + }); + + itRunsE2eOnly("should support font shadows", async () => { + const img = await surface.drawParagraph( + (Skia) => + Skia.ParagraphBuilder.Make() + .pushStyle({ + color: Skia.Color("black"), + fontSize: 25, + shadows: [ + { + color: Skia.Color("#ff000044"), + blurRadius: 4, + offset: { x: 4, y: 4 }, + }, + ], + }) + .addText("Hello Skia with red shadow") + .build(), + 150 + ); + checkImage( + img, + `snapshots/paragraph/paragraph-text-style-font-shadow-${surface.OS}.png` + ); + }); +}); diff --git a/package/src/renderer/__tests__/setup.tsx b/package/src/renderer/__tests__/setup.tsx index dc042a1168..5d7b48a515 100644 --- a/package/src/renderer/__tests__/setup.tsx +++ b/package/src/renderer/__tests__/setup.tsx @@ -12,8 +12,8 @@ import type * as SkiaExports from "../../index"; import { JsiSkApi } from "../../skia/web/JsiSkia"; import type { Node } from "../../dom/nodes"; import { JsiSkDOM } from "../../dom/nodes"; -import { Group } from "../components"; -import type { SkImage, SkFont, Skia } from "../../skia/types"; +import { Group, Paragraph } from "../components"; +import type { SkImage, SkFont, Skia, SkParagraph } from "../../skia/types"; import { isPath } from "../../skia/types"; import { E2E } from "../../__tests__/setup"; import { SkiaRoot } from "../Reconciler"; @@ -320,6 +320,11 @@ interface TestingSurface { fn: (Skia: Skia, ctx: Ctx) => R, ctx?: Ctx ): Promise; + drawParagraph( + fn: (Skia: Skia, ctx: Ctx) => SkParagraph, + width?: number, + ctx?: Ctx + ): Promise; draw(node: ReactNode): Promise; screen(name: string): Promise; width: number; @@ -343,6 +348,26 @@ class LocalSurface implements TestingSurface { return Promise.resolve(fn(global.SkiaApi, ctx ?? ({} as any))); } + async drawParagraph( + fn: (Skia: Skia, ctx: Ctx) => SkParagraph, + paragraphWidth?: number, + ctx?: Ctx + ) { + const paragraph = await this.eval(fn, ctx); + const { surface: ckSurface, draw } = mountCanvas( + + + + ); + draw(); + return Promise.resolve(ckSurface.makeImageSnapshot()); + } + draw(node: ReactNode): Promise { const { surface: ckSurface, draw } = mountCanvas( {node} @@ -363,6 +388,38 @@ class RemoteSurface implements TestingSurface { readonly OS = global.testOS; readonly arch = global.testArch; + eval( + fn: (Skia: Skia, ctx: Ctx) => any, + context?: Ctx + ): Promise { + const ctx = this.prepareContext(context); + const body = { code: fn.toString(), ctx }; + return this.handleImageResponse(JSON.stringify(body), true); + } + + async drawParagraph( + fn: (Skia: Skia, ctx: Ctx) => SkParagraph, + paragraphWidth?: number, + context?: Ctx + ) { + const ctx = this.prepareContext(context); + const body = { + paragraph: fn.toString(), + ctx, + paragraphWidth: paragraphWidth ?? this.width, + }; + this.client.send(JSON.stringify(body)); + return this.handleImageResponse(JSON.stringify(body)); + } + + draw(node: ReactNode) { + return this.handleImageResponse(serialize(node)); + } + + screen(screen: string) { + return this.handleImageResponse(JSON.stringify({ screen })); + } + private get client() { if (global.testClient === null) { throw new Error("Client is not connected. Did you call init?"); @@ -370,39 +427,25 @@ class RemoteSurface implements TestingSurface { return global.testClient!; } - eval( - fn: (Skia: Skia, ctx: Ctx) => any, - context?: Ctx - ): Promise { - return new Promise((resolve) => { - this.client.once("message", (raw: Buffer) => { - resolve(JSON.parse(raw.toString())); + private prepareContext(context?: Ctx): EvalContext { + const ctx: EvalContext = {}; + if (context) { + Object.keys(context).forEach((key) => { + ctx[key] = serializeSkOjects(context[key]); }); - const ctx: EvalContext = {}; - if (context) { - Object.keys(context).forEach((key) => { - ctx[key] = serializeSkOjects(context[key]); - }); - } - this.client.send(JSON.stringify({ code: fn.toString(), ctx })); - }); - } - - draw(node: ReactNode): Promise { - return new Promise((resolve) => { - this.client.once("message", (raw: Buffer) => { - resolve(this.decodeImage(raw)); - }); - this.client!.send(serialize(node)); - }); + } + return ctx; } - screen(screen: string): Promise { + private handleImageResponse( + body: string, + json?: boolean + ): Promise { return new Promise((resolve) => { this.client.once("message", (raw: Buffer) => { - resolve(this.decodeImage(raw)); + resolve(json ? JSON.parse(raw.toString()) : this.decodeImage(raw)); }); - this.client.send(JSON.stringify({ screen })); + this.client.send(body); }); } diff --git a/package/src/renderer/components/index.ts b/package/src/renderer/components/index.ts index e7819a43e7..f8ce590cb5 100644 --- a/package/src/renderer/components/index.ts +++ b/package/src/renderer/components/index.ts @@ -15,3 +15,5 @@ export * from "./Mask"; export * from "./Paint"; export * from "./Blend"; export * from "./Drawing"; + +export * from "./paragraph"; diff --git a/package/src/renderer/components/paragraph/Paragraph.tsx b/package/src/renderer/components/paragraph/Paragraph.tsx new file mode 100644 index 0000000000..98a3e8125e --- /dev/null +++ b/package/src/renderer/components/paragraph/Paragraph.tsx @@ -0,0 +1,8 @@ +import React from "react"; + +import type { ParagraphProps } from "../../../dom/types/Paragraph"; +import type { SkiaProps } from "../../processors"; + +export const Paragraph = (props: SkiaProps) => { + return ; +}; diff --git a/package/src/renderer/components/paragraph/index.ts b/package/src/renderer/components/paragraph/index.ts new file mode 100644 index 0000000000..8bf2f8fcf4 --- /dev/null +++ b/package/src/renderer/components/paragraph/index.ts @@ -0,0 +1 @@ +export * from "./Paragraph"; diff --git a/package/src/skia/__tests__/assets/Pacifico-Regular.ttf b/package/src/skia/__tests__/assets/Pacifico-Regular.ttf new file mode 100644 index 0000000000..e7def95d3f Binary files /dev/null and b/package/src/skia/__tests__/assets/Pacifico-Regular.ttf differ diff --git a/package/src/skia/__tests__/assets/Roboto-Bold.ttf b/package/src/skia/__tests__/assets/Roboto-Bold.ttf new file mode 100755 index 0000000000..e612852d25 Binary files /dev/null and b/package/src/skia/__tests__/assets/Roboto-Bold.ttf differ diff --git a/package/src/skia/__tests__/assets/Roboto-Italic.ttf b/package/src/skia/__tests__/assets/Roboto-Italic.ttf new file mode 100644 index 0000000000..1b5eaa361c Binary files /dev/null and b/package/src/skia/__tests__/assets/Roboto-Italic.ttf differ diff --git a/package/src/skia/types/Paragraph/Paragraph.ts b/package/src/skia/types/Paragraph/Paragraph.ts new file mode 100644 index 0000000000..e1f491bfbb --- /dev/null +++ b/package/src/skia/types/Paragraph/Paragraph.ts @@ -0,0 +1,59 @@ +import type { SkCanvas } from "../Canvas"; +import type { SkJSIInstance } from "../JsiInstance"; +import type { SkRect } from "../Rect"; + +import type { SkTextDirection } from "./ParagraphStyle"; + +export interface SkRectWithDirection { + rect: SkRect; + direction: SkTextDirection; +} + +export interface SkParagraph extends SkJSIInstance<"Paragraph"> { + /** + * Calculates the position of the the glyphs in the paragraph + * @param width Max width of the paragraph + */ + layout(width: number): void; + /** + * Paints the paragraph to the provded canvas + * @param canvas Canvas to paint into + * @param x X coordinate to paint at + * @param y Y coordinate to paint at + */ + paint(canvas: SkCanvas, x: number, y: number): void; + /** + * Returns the height of the paragraph. This method requires the layout + * method to have been called first. + */ + getHeight(): number; + /** + * Returns the max width of the paragraph. This method requires the layout + * method to have been called first. + */ + getMaxWidth(): number; + /** + * Returns the index of the glyph at the given position. This method requires + * the layout method to have been called first. + * @param x X coordinate of the position + * @param y Y coordinate of the position + */ + getGlyphPositionAtCoordinate(x: number, y: number): number; + /** + * Returns the bounding boxes of the glyphs in the given range. This method + * requires the layout method to have been called first. + * @param start Start index of the range + * @param end End index of the range + */ + getRectsForRange(start: number, end: number): SkRect[]; + /** + * Returns the bounding boxes for all lines in the paragraph. This method + * requires the layout method to have been called first. + */ + getLineMetrics(): Array; + /** + * Returns a list of rects with direction info for the placeholders added + * to the paragraph. + */ + getRectsForPlaceholders(): SkRectWithDirection[]; +} diff --git a/package/src/skia/types/Paragraph/ParagraphBuilder.ts b/package/src/skia/types/Paragraph/ParagraphBuilder.ts new file mode 100644 index 0000000000..a0e253ed46 --- /dev/null +++ b/package/src/skia/types/Paragraph/ParagraphBuilder.ts @@ -0,0 +1,96 @@ +import type { SkJSIInstance } from "../JsiInstance"; +import type { SkPaint } from "../Paint"; + +import type { SkParagraph } from "./Paragraph"; +import type { SkParagraphStyle } from "./ParagraphStyle"; +import type { SkTextStyle, TextBaseline } from "./TextStyle"; +import type { SkTypefaceFontProvider } from "./TypefaceFontProvider"; + +export interface ParagraphBuilderFactory { + /** + * Creates a new ParagraphBuilder object from custom fonts. + * @param paragraphStyle Initial paragraph style + * @param typefaceProvider Typeface provider + */ + Make( + paragraphStyle?: SkParagraphStyle, + typefaceProvider?: SkTypefaceFontProvider + ): SkParagraphBuilder; +} + +export enum PlaceholderAlignment { + /// Match the baseline of the placeholder with the baseline. + Baseline = 0, + + /// Align the bottom edge of the placeholder with the baseline such that the + /// placeholder sits on top of the baseline. + AboveBaseline, + + /// Align the top edge of the placeholder with the baseline specified in + /// such that the placeholder hangs below the baseline. + BelowBaseline, + + /// Align the top edge of the placeholder with the top edge of the font. + /// When the placeholder is very tall, the extra space will hang from + /// the top and extend through the bottom of the line. + Top, + + /// Align the bottom edge of the placeholder with the top edge of the font. + /// When the placeholder is very tall, the extra space will rise from + /// the bottom and extend through the top of the line. + Bottom, + + /// Align the middle of the placeholder with the middle of the text. When the + /// placeholder is very tall, the extra space will grow equally from + /// the top and bottom of the line. + Middle, +} + +export interface SkParagraphBuilder extends SkJSIInstance<"ParagraphBuilder"> { + /** + * Creates a Paragraph object from the builder and the inputs given to the builder. + */ + build(): SkParagraph; + /** + * Restores the builder to its initial empty state. + */ + reset(): void; + /** + * Pushes a text-style to the builder + * @param style Style to push + * @param foregroundPaint Foreground paint object + * @param backgroundPaint Background paint object + * @returns The builder + */ + pushStyle: ( + style: SkTextStyle, + foregroundPaint?: SkPaint | undefined, + backgroundPaint?: SkPaint | undefined + ) => SkParagraphBuilder; + /** + * Pops the current text style from the builder + * @returns The builder + */ + pop: () => SkParagraphBuilder; + /** + * Adds text to the builder + * @param text + * @returns The builder + */ + addText: (text: string) => SkParagraphBuilder; + /** + * Pushes the information required to leave an open space. + * @param width + * @param height + * @param alignment + * @param baseline + * @param offset + */ + addPlaceholder( + width?: number, + height?: number, + alignment?: PlaceholderAlignment, + baseline?: TextBaseline, + offset?: number + ): SkParagraphBuilder; +} diff --git a/package/src/skia/types/Paragraph/ParagraphStyle.ts b/package/src/skia/types/Paragraph/ParagraphStyle.ts new file mode 100644 index 0000000000..21ddc35970 --- /dev/null +++ b/package/src/skia/types/Paragraph/ParagraphStyle.ts @@ -0,0 +1,45 @@ +import type { SkTextFontStyle, SkTextStyle } from "./TextStyle"; + +export enum SkTextDirection { + RTL = 0, + LTR = 1, +} +export enum SkTextAlign { + Left = 0, + Right, + Center, + Justify, + Start, + End, +} + +export interface SkStrutStyle { + strutEnabled?: boolean; + fontFamilies?: string[]; + fontStyle?: SkTextFontStyle; + fontSize?: number; + heightMultiplier?: number; + halfLeading?: boolean; + leading?: number; + forceStrutHeight?: boolean; +} + +export enum SkTextHeightBehavior { + All = 0x0, + DisableFirstAscent = 0x1, + DisableLastDescent = 0x2, + DisableAll = 0x1 | 0x2, +} + +export interface SkParagraphStyle { + disableHinting?: boolean; + ellipsis?: string; + heightMultiplier?: number; + maxLines?: number; + replaceTabCharacters?: boolean; + strutStyle?: SkStrutStyle; + textAlign?: SkTextAlign; + textDirection?: SkTextDirection; + textHeightBehavior?: SkTextHeightBehavior; + textStyle?: SkTextStyle; +} diff --git a/package/src/skia/types/Paragraph/TextStyle.ts b/package/src/skia/types/Paragraph/TextStyle.ts new file mode 100644 index 0000000000..bb938165dc --- /dev/null +++ b/package/src/skia/types/Paragraph/TextStyle.ts @@ -0,0 +1,70 @@ +import type { SkColor } from "../Color"; +import type { FontSlant, FontWeight, FontWidth } from "../Font"; +import type { SkPoint } from "../Point"; + +export enum TextDecoration { + NoDecoration = 0x0, + Underline = 0x1, + Overline = 0x2, + LineThrough = 0x4, +} + +export enum TextDecorationStyle { + Solid = 0, + Double, + Dotted, + Dashed, + Wavy, +} + +export interface SkTextShadow { + color?: SkColor; + /** + * 2d array for x and y offset. Defaults to [0, 0] + */ + offset?: SkPoint; + blurRadius?: number; +} + +export interface SkTextFontFeatures { + name: string; + value: number; +} + +export interface SkTextFontStyle { + weight?: FontWeight; + width?: FontWidth; + slant?: FontSlant; +} + +export interface SkTextFontVariations { + axis: string; + value: number; +} + +export enum TextBaseline { + Alphabetic = 0, + Ideographic, +} + +export interface SkTextStyle { + backgroundColor?: SkColor; + color?: SkColor; + decoration?: number; + decorationColor?: SkColor; + decorationThickness?: number; + decorationStyle?: TextDecoration; + fontFamilies?: string[]; + fontFeatures?: SkTextFontFeatures[]; + fontSize?: number; + fontStyle?: SkTextFontStyle; + fontVariations?: SkTextFontVariations[]; + foregroundColor?: SkColor; + heightMultiplier?: number; + halfLeading?: boolean; + letterSpacing?: number; + locale?: string; + shadows?: SkTextShadow[]; + textBaseline?: TextBaseline; + wordSpacing?: number; +} diff --git a/package/src/skia/types/Paragraph/index.ts b/package/src/skia/types/Paragraph/index.ts new file mode 100644 index 0000000000..1db78957c4 --- /dev/null +++ b/package/src/skia/types/Paragraph/index.ts @@ -0,0 +1,6 @@ +export * from "./TypefaceFontProvider"; +export * from "./TypefaceFontProviderFactory"; +export * from "./Paragraph"; +export * from "./ParagraphBuilder"; +export * from "./ParagraphStyle"; +export * from "./TextStyle"; diff --git a/package/src/skia/types/Skia.ts b/package/src/skia/types/Skia.ts index b8d61340c2..ef71708a8b 100644 --- a/package/src/skia/types/Skia.ts +++ b/package/src/skia/types/Skia.ts @@ -29,6 +29,7 @@ import type { PictureFactory, SkPictureRecorder } from "./Picture"; import type { Color, SkColor } from "./Color"; import type { TypefaceFontProviderFactory } from "./Paragraph/TypefaceFontProviderFactory"; import type { AnimatedImageFactory } from "./AnimatedImage"; +import type { ParagraphBuilderFactory } from "./Paragraph/ParagraphBuilder"; /** * Declares the interface for the native Skia API @@ -84,4 +85,6 @@ export interface Skia { SVG: SVGFactory; TextBlob: TextBlobFactory; Surface: SurfaceFactory; + // Paragraph + ParagraphBuilder: ParagraphBuilderFactory; } diff --git a/package/src/skia/types/index.ts b/package/src/skia/types/index.ts index 9017fdb22d..004d556887 100644 --- a/package/src/skia/types/index.ts +++ b/package/src/skia/types/index.ts @@ -27,3 +27,4 @@ export * from "./JsiInstance"; export * from "./Skia"; export * from "./TextBlob"; export * from "./Size"; +export * from "./Paragraph"; diff --git a/package/src/skia/web/JsiSkParagraph.ts b/package/src/skia/web/JsiSkParagraph.ts new file mode 100644 index 0000000000..312fec3783 --- /dev/null +++ b/package/src/skia/web/JsiSkParagraph.ts @@ -0,0 +1,69 @@ +import type { CanvasKit, Paragraph } from "canvaskit-wasm"; + +import type { SkRect, SkRectWithDirection, SkParagraph } from "../types"; + +import { HostObject } from "./Host"; +import type { JsiSkCanvas } from "./JsiSkCanvas"; + +export class JsiSkParagraph + extends HostObject + implements SkParagraph +{ + constructor(CanvasKit: CanvasKit, ref: Paragraph) { + super(CanvasKit, ref, "Paragraph"); + } + + layout(width: number): void { + this.ref.layout(width); + } + paint(canvas: JsiSkCanvas, x: number, y: number): void { + canvas.ref.drawParagraph(this.ref, x, y); + } + getHeight(): number { + return this.ref.getHeight(); + } + getMaxWidth(): number { + return this.ref.getMaxWidth(); + } + getGlyphPositionAtCoordinate(x: number, y: number): number { + return this.ref.getGlyphPositionAtCoordinate(x, y).pos; + } + getRectsForPlaceholders(): SkRectWithDirection[] { + return this.ref.getRectsForPlaceholders().map(({ rect, dir }) => ({ + rect: { + x: rect.at(0)!, + y: rect.at(1)!, + width: rect.at(2)!, + height: rect.at(3)!, + }, + direction: dir.value, + })); + } + getRectsForRange(start: number, end: number): SkRect[] { + return this.ref + .getRectsForRange( + start, + end, + { value: 0 } /** kTight */, + { value: 0 } /** kTight */ + ) + .map(({ rect }) => ({ + x: rect[0], + y: rect[1], + width: rect[2], + height: rect[3], + })); + } + getLineMetrics(): SkRect[] { + return this.ref.getLineMetrics().map((r, index) => ({ + x: r.left, + y: index * r.height, + width: r.width, + height: r.height, + })); + } + + dispose() { + this.ref.delete(); + } +} diff --git a/package/src/skia/web/JsiSkParagraphBuilder.ts b/package/src/skia/web/JsiSkParagraphBuilder.ts new file mode 100644 index 0000000000..7436fe367c --- /dev/null +++ b/package/src/skia/web/JsiSkParagraphBuilder.ts @@ -0,0 +1,99 @@ +import type { + CanvasKit, + InputColor, + Paint, + ParagraphBuilder, + TextStyle, +} from "canvaskit-wasm"; + +import type { + SkParagraphBuilder, + SkParagraph, + SkTextStyle, + SkPaint, +} from "../types"; +import { PlaceholderAlignment, TextBaseline } from "../types"; + +import { HostObject } from "./Host"; +import { JsiSkParagraph } from "./JsiSkParagraph"; +import { JsiSkTextStyle } from "./JsiSkTextStyle"; +import { JsiSkPaint } from "./JsiSkPaint"; + +export class JsiSkParagraphBuilder + extends HostObject + implements SkParagraphBuilder +{ + constructor(CanvasKit: CanvasKit, ref: ParagraphBuilder) { + super(CanvasKit, ref, "ParagraphBuilder"); + } + + addPlaceholder( + width: number | undefined = 0, + height: number | undefined = 0, + alignment: PlaceholderAlignment | undefined = PlaceholderAlignment.Baseline, + baseline: TextBaseline | undefined = TextBaseline.Alphabetic, + offset: number | undefined = 0 + ): SkParagraphBuilder { + this.ref.addPlaceholder( + width, + height, + { value: alignment }, + { value: baseline }, + offset + ); + return this; + } + addText(text: string): SkParagraphBuilder { + this.ref.addText(text); + return this; + } + + build(): SkParagraph { + return new JsiSkParagraph(this.CanvasKit, this.ref.build()); + } + + reset(): void { + this.ref.reset(); + } + + pushStyle( + style: SkTextStyle, + foregroundPaint?: SkPaint | undefined, + backgroundPaint?: SkPaint | undefined + ): SkParagraphBuilder { + const textStyle: TextStyle = JsiSkTextStyle.toTextStyle(style); + if (foregroundPaint || backgroundPaint) { + // Due the canvaskit API not exposing textStyle methods, + // we set the default paint color to black for the foreground + // and transparent for the background + const fg: Paint = foregroundPaint + ? JsiSkPaint.fromValue(foregroundPaint) + : this.makePaint(textStyle.color ?? Float32Array.of(0, 0, 0, 1)); + const bg: Paint = backgroundPaint + ? JsiSkPaint.fromValue(backgroundPaint) + : this.makePaint( + textStyle.backgroundColor ?? Float32Array.of(0, 0, 0, 0) + ); + this.ref.pushPaintStyle(new this.CanvasKit.TextStyle(textStyle), fg, bg); + } else { + this.ref.pushStyle(new this.CanvasKit.TextStyle(textStyle)); + } + + return this; + } + + pop(): SkParagraphBuilder { + this.ref.pop(); + return this; + } + + dispose() { + this.ref.delete(); + } + + private makePaint(color: InputColor) { + const paint = new this.CanvasKit.Paint(); + paint.setColor(color); + return paint; + } +} diff --git a/package/src/skia/web/JsiSkParagraphBuilderFactory.ts b/package/src/skia/web/JsiSkParagraphBuilderFactory.ts new file mode 100644 index 0000000000..0952601a35 --- /dev/null +++ b/package/src/skia/web/JsiSkParagraphBuilderFactory.ts @@ -0,0 +1,42 @@ +import type { CanvasKit } from "canvaskit-wasm"; + +import type { + ParagraphBuilderFactory, + SkParagraphStyle, + SkTypefaceFontProvider, +} from "../types"; + +import { Host } from "./Host"; +import { JsiSkParagraphBuilder } from "./JsiSkParagraphBuilder"; +import { JsiSkParagraphStyle } from "./JsiSkParagraphStyle"; +import { JsiSkTypeface } from "./JsiSkTypeface"; + +export class JsiSkParagraphBuilderFactory + extends Host + implements ParagraphBuilderFactory +{ + constructor(CanvasKit: CanvasKit) { + super(CanvasKit); + } + + Make( + paragraphStyle: SkParagraphStyle, + typefaceProvider?: SkTypefaceFontProvider + ) { + const style = new this.CanvasKit.ParagraphStyle( + JsiSkParagraphStyle.toParagraphStyle(this.CanvasKit, paragraphStyle ?? {}) + ); + if (typefaceProvider === undefined) { + throw new Error( + "SkTypefaceFontProvider is required on React Native Web." + ); + } + return new JsiSkParagraphBuilder( + this.CanvasKit, + this.CanvasKit.ParagraphBuilder.MakeFromFontProvider( + style, + JsiSkTypeface.fromValue(typefaceProvider) + ) + ); + } +} diff --git a/package/src/skia/web/JsiSkParagraphStyle.ts b/package/src/skia/web/JsiSkParagraphStyle.ts new file mode 100644 index 0000000000..799835850f --- /dev/null +++ b/package/src/skia/web/JsiSkParagraphStyle.ts @@ -0,0 +1,65 @@ +import type { CanvasKit, ParagraphStyle } from "canvaskit-wasm"; + +import { SkTextDirection, type SkParagraphStyle } from "../types"; + +export class JsiSkParagraphStyle { + static toParagraphStyle( + ck: CanvasKit, + value: SkParagraphStyle + ): ParagraphStyle { + // Seems like we need to provide the textStyle.color value, otherwise + // the constructor crashes. + const ps = new ck.ParagraphStyle({ textStyle: { color: ck.BLACK } }); + + ps.disableHinting = value.disableHinting ?? ps.disableHinting; + ps.ellipsis = value.ellipsis ?? ps.ellipsis; + ps.heightMultiplier = value.heightMultiplier ?? ps.heightMultiplier; + ps.maxLines = value.maxLines ?? ps.maxLines; + ps.replaceTabCharacters = + value.replaceTabCharacters ?? ps.replaceTabCharacters; + ps.textAlign = + value.textAlign !== undefined + ? { value: value.textAlign } + : undefined ?? ps.textAlign; + ps.textDirection = + value.textDirection !== undefined + ? { value: value.textDirection === SkTextDirection.LTR ? 1 : 0 } + : ps.textDirection; + ps.textHeightBehavior = + value.textHeightBehavior !== undefined + ? { value: value.textHeightBehavior } + : ps.textHeightBehavior; + + ps.strutStyle = ps.strutStyle ?? {}; + ps.strutStyle.fontFamilies = + value.strutStyle?.fontFamilies ?? ps.strutStyle.fontFamilies; + ps.strutStyle.fontSize = + value.strutStyle?.fontSize ?? ps.strutStyle.fontSize; + ps.strutStyle.heightMultiplier = + value.strutStyle?.heightMultiplier ?? ps.strutStyle.heightMultiplier; + ps.strutStyle.leading = value.strutStyle?.leading ?? ps.strutStyle.leading; + ps.strutStyle.forceStrutHeight = + value.strutStyle?.forceStrutHeight ?? ps.strutStyle.forceStrutHeight; + + ps.strutStyle.fontStyle = ps.strutStyle.fontStyle ?? {}; + + ps.strutStyle.fontStyle.slant = + value.strutStyle?.fontStyle?.slant !== undefined + ? { value: value.strutStyle.fontStyle.slant } + : ps.strutStyle.fontStyle.slant; + ps.strutStyle.fontStyle.width = + value.strutStyle?.fontStyle?.width !== undefined + ? { value: value.strutStyle.fontStyle.width } + : ps.strutStyle.fontStyle.width; + ps.strutStyle.fontStyle.weight = + value.strutStyle?.fontStyle?.weight !== undefined + ? { value: value.strutStyle.fontStyle.weight } + : ps.strutStyle.fontStyle.weight; + ps.strutStyle.halfLeading = + value.strutStyle?.halfLeading ?? ps.strutStyle.halfLeading; + ps.strutStyle.strutEnabled = + value.strutStyle?.strutEnabled ?? ps.strutStyle.strutEnabled; + + return ps; + } +} diff --git a/package/src/skia/web/JsiSkTextStyle.ts b/package/src/skia/web/JsiSkTextStyle.ts new file mode 100644 index 0000000000..4f80648f94 --- /dev/null +++ b/package/src/skia/web/JsiSkTextStyle.ts @@ -0,0 +1,53 @@ +import type { TextStyle } from "canvaskit-wasm"; + +import type { SkTextStyle } from "../types"; + +export class JsiSkTextStyle { + static toTextStyle(value: SkTextStyle): TextStyle { + return { + backgroundColor: value.backgroundColor, + color: value.color, + decoration: value.decoration, + decorationColor: value.decorationColor, + decorationStyle: value.decorationStyle + ? { value: value.decorationStyle } + : undefined, + decorationThickness: value.decorationThickness, + fontFamilies: value.fontFamilies, + fontSize: value.fontSize, + fontStyle: value.fontStyle + ? { + slant: value.fontStyle.slant + ? { value: value.fontStyle.slant } + : undefined, + weight: value.fontStyle.weight + ? { value: value.fontStyle.weight } + : undefined, + width: value.fontStyle.width + ? { value: value.fontStyle.width } + : undefined, + } + : undefined, + fontFeatures: value.fontFeatures, + foregroundColor: value.foregroundColor, + fontVariations: value.fontVariations, + halfLeading: value.halfLeading, + heightMultiplier: value.heightMultiplier, + letterSpacing: value.letterSpacing, + locale: value.locale, + shadows: value.shadows + ? value.shadows.map((shadow) => ({ + blurRadius: shadow.blurRadius, + color: shadow.color, + offset: shadow.offset + ? [shadow.offset.x, shadow.offset.y] + : undefined, + })) + : undefined, + textBaseline: value.textBaseline + ? { value: value.textBaseline } + : undefined, + wordSpacing: value.wordSpacing, + }; + } +} diff --git a/package/src/skia/web/JsiSkia.ts b/package/src/skia/web/JsiSkia.ts index 6567b31ffa..edb2da8d85 100644 --- a/package/src/skia/web/JsiSkia.ts +++ b/package/src/skia/web/JsiSkia.ts @@ -40,6 +40,7 @@ import { JsiSkTypeface } from "./JsiSkTypeface"; import { JsiSkTypefaceFontProviderFactory } from "./JsiSkTypefaceFontProviderFactory"; import { JsiSkFontMgrFactory } from "./JsiSkFontMgrFactory"; import { JsiSkAnimatedImageFactory } from "./JsiSkAnimatedImageFactory"; +import { JsiSkParagraphBuilderFactory } from "./JsiSkParagraphBuilderFactory"; export const JsiSkApi = (CanvasKit: CanvasKit): Skia => ({ Point: (x: number, y: number) => @@ -108,4 +109,5 @@ export const JsiSkApi = (CanvasKit: CanvasKit): Skia => ({ Surface: new JsiSkSurfaceFactory(CanvasKit), TypefaceFontProvider: new JsiSkTypefaceFontProviderFactory(CanvasKit), FontMgr: new JsiSkFontMgrFactory(CanvasKit), + ParagraphBuilder: new JsiSkParagraphBuilderFactory(CanvasKit), }); diff --git a/scripts/build-libgrapheme-ios.ts b/scripts/build-libgrapheme-ios.ts new file mode 100644 index 0000000000..7d21e64457 --- /dev/null +++ b/scripts/build-libgrapheme-ios.ts @@ -0,0 +1,48 @@ +import os from "os"; +import fs from "fs"; +import { executeCmdSync } from "./utils"; + +// Build instructions for building build-libgrapheme-ios +export const buildLibGraphemeiOS = async () => { + // Empty the generate_headers.py file + console.log("Patching the Skia buildscript 'generate_headers.py'..."); + const file = fs.openSync( + "./third_party/libgrapheme/generate_headers.py", + "w" + ); + fs.writeSync( + file, + "print('[generate_headers.py] This file has been patched by the RN Skia build script.')" + ); + fs.closeSync(file); + console.log("Finished patching generate_headers.py."); + + console.log("Building libgrapheme for iOS..."); + + // Change to the third_party/libgrapheme directory + const currentDir = process.cwd(); + try { + process.chdir("./third_party/externals/libgrapheme"); + // Check if the output has been created - if so skip the build + if (!fs.existsSync("./gen/case.o")) { + // Run configure + console.log("Configuring libgrapheme..."); + executeCmdSync("./configure"); + // Up the file handle limit on mac: + if (os.platform() === "darwin") { + console.log( + "Extending file handle count on Mac to avoid `Too many open files` error..." + ); + executeCmdSync("ulimit -n 4096"); + } + // Run make + console.log("Building libgrapheme..."); + executeCmdSync("make"); + console.log("libgrapheme successfully built."); + } else { + console.log("Skipping configuring libgrapheme as it is already built."); + } + } finally { + process.chdir(currentDir); + } +}; diff --git a/scripts/build-skia.ts b/scripts/build-skia.ts index 27ad48c7dd..43a18464b4 100644 --- a/scripts/build-skia.ts +++ b/scripts/build-skia.ts @@ -166,11 +166,7 @@ const processOutput = (platformName: PlatformName, targetName: string) => { try { console.log(`Entering directory ${SkiaDir}`); - console.log("Running gclient sync..."); process.chdir(SkiaDir); - // Start by running sync - executeCmdSync("PATH=../depot_tools/:$PATH python3 tools/git-sync-deps"); - console.log("gclient sync done"); // Find platform/target const platform = configurations[SelectedPlatform]; @@ -186,6 +182,22 @@ try { exit(1); } + // lets check for any dependencies + if (platform.dependencies) { + console.log(`Found dependencies for platform ${SelectedPlatform}`); + platform.dependencies.forEach((dep) => { + console.log(`Running dependency ${dep.name}`); + dep.executable(); + }); + } + + // Run glient sync + console.log("Running gclient sync..."); + + // Start by running sync + executeCmdSync("PATH=../depot_tools/:$PATH python3 tools/git-sync-deps"); + console.log("gclient sync done"); + try { // Configure the platform if (!configurePlatform(SelectedPlatform, SelectedTarget)) { diff --git a/scripts/copy-skia-module-headers.ts b/scripts/copy-skia-module-headers.ts index 2d79906485..93bfdff1db 100644 --- a/scripts/copy-skia-module-headers.ts +++ b/scripts/copy-skia-module-headers.ts @@ -17,6 +17,15 @@ const copyModule = (module: string) => [ `cp -a ./externals/skia/src/core/SkPathPriv.h ./package/cpp/skia/src/core/.`, `cp -a ./externals/skia/src/core/SkChecksum.h ./package/cpp/skia/src/core/.`, `cp -a ./externals/skia/src/core/SkTHash.h ./package/cpp/skia/src/core/.`, + + "cp -a ./externals/skia/src/core/SkLRUCache.h ./package/cpp/skia/src/core/.", + + "mkdir -p ./package/cpp/skia/src/base", + "cp -a ./externals/skia/src/base/SkTInternalLList.h ./package/cpp/skia/src/base/.", + "cp -a ./externals/skia/src/base/SkUTF.h ./package/cpp/skia/src/base/.", + + "mkdir -p ./package/cpp/skia/modules/skunicode/include/", + "cp -a externals/skia/modules/skunicode/include/SkUnicode.h ./package/cpp/skia/modules/skunicode/include/.", ].map((cmd) => { console.log(cmd); executeCmdSync(cmd); diff --git a/scripts/skia-configuration.ts b/scripts/skia-configuration.ts index 3eccd6cfeb..f00e7bd5c5 100644 --- a/scripts/skia-configuration.ts +++ b/scripts/skia-configuration.ts @@ -1,3 +1,4 @@ +import { buildLibGraphemeiOS } from "./build-libgrapheme-ios"; import { executeCmdSync } from "./utils"; const NdkDir: string = process.env.ANDROID_NDK ?? ""; @@ -10,24 +11,29 @@ const NoParagraphArgs = [ // To build the paragraph API: // On Android: we use system ICU -// On iOS: we use neither system nor client ICU +// On iOS: we use libgrapheme const CommonParagraphArgs = [ ["skia_enable_paragraph", true], ["skia_use_system_icu", false], ["skia_use_harfbuzz", true], ["skia_use_system_harfbuzz", false], ]; -const ParagraphArgsAndroid = BUILD_WITH_PARAGRAPH ? [ - ...CommonParagraphArgs, - ["skia_use_icu", true], - ["skia_use_runtime_icu", true], -] : NoParagraphArgs; +const ParagraphArgsAndroid = BUILD_WITH_PARAGRAPH + ? [ + ...CommonParagraphArgs, + ["skia_use_icu", true], + ["skia_use_runtime_icu", true], + ] + : NoParagraphArgs; -const ParagraphArgsIOS = BUILD_WITH_PARAGRAPH ? [ - ...CommonParagraphArgs, - ["skia_use_icu", false], - ["skia_use_client_icu", true], -] : NoParagraphArgs; +const ParagraphArgsIOS = BUILD_WITH_PARAGRAPH + ? [ + ...CommonParagraphArgs, + ["skia_use_icu", false], + ["skia_use_client_icu", false], + ["skia_use_libgrapheme", true], + ] + : NoParagraphArgs; const ParagraphOutputs = BUILD_WITH_PARAGRAPH ? ["libskparagraph.a", "libskunicode.a"] @@ -67,6 +73,7 @@ export type Platform = { outputRoot: string; outputNames: string[]; options?: Arg[]; + dependencies?: { name: string; executable: () => void }[]; }; export const configurations: Configuration = { @@ -129,6 +136,7 @@ export const configurations: Configuration = { ["extra_cflags", '["-target", "arm64-apple-ios-simulator"]'], ["extra_asmflags", '["-target", "arm64-apple-ios-simulator"]'], ["extra_ldflags", '["-target", "arm64-apple-ios-simulator"]'], + ["ios_use_simulator", true], ], }, x64: { @@ -145,7 +153,7 @@ export const configurations: Configuration = { ["skia_use_metal", true], ["cc", '"clang"'], ["cxx", '"clang++"'], - ...ParagraphArgsIOS + ...ParagraphArgsIOS, ], outputRoot: "package/libs/ios", outputNames: [ @@ -156,5 +164,11 @@ export const configurations: Configuration = { "libsksg.a", ...ParagraphOutputs, ], + dependencies: [ + { + name: "libgrapheme", + executable: buildLibGraphemeiOS, + }, + ], }, };