diff --git a/apps/docs/docs/text/paragraph.md b/apps/docs/docs/text/paragraph.md index cd1d1adceb..a0eebc91ec 100644 --- a/apps/docs/docs/text/paragraph.md +++ b/apps/docs/docs/text/paragraph.md @@ -236,6 +236,61 @@ const MyParagraph = () => { +### Glyph-Tight Bounds + +:::info + +`getPath()` and `extendedVisit()` are not available on Web. + +::: + +`getHeight()`, `getLineMetrics()`, and `getRectsForRange()` are all based on font metrics: they reserve the full ascent and descent of the fonts on each line, regardless of the glyphs actually present. For instance, `"Hello"` and `"Typography"` report the same height even though only the latter has descenders. + + + +To measure the exact ink bounds of the rendered text (with font fallbacks already applied), convert a line into a path with `getPath(lineNumber)` and compute its tight bounds. Note that color glyphs (e.g. emojis) cannot be converted to a path and are skipped. + +```tsx twoslash +import { Skia } from "@shopify/react-native-skia"; + +const paragraph = Skia.ParagraphBuilder.Make() + .addText("Hello") + .build(); +paragraph.layout(200); +// The metrics-based height (reserves ascent + descent) +const height = paragraph.getHeight(); +// The exact bounds of the glyphs on the first line +const inkBounds = paragraph.getPath(0)!.computeTightBounds(); +``` + +For full access to the computed layout, `extendedVisit(visitor)` invokes the visitor once for every run of glyphs with the resolved font (after fallback), glyph ids, positions and per-glyph tight ink bounds, and once with a `null` info to signal the end of each line. + +```tsx twoslash +import { Skia } from "@shopify/react-native-skia"; + +const paragraph = Skia.ParagraphBuilder.Make() + .addText("Hello你好") + .build(); +paragraph.layout(200); +paragraph.extendedVisit((lineNumber, info) => { + if (info === null) { + // End of the line + return; + } + // The index of the line this run belongs to + const line = lineNumber; + // The font resolved for this run (e.g. a fallback font for CJK glyphs) + const font = info.font; + // Per-glyph ids, positions and tight ink bounds. + // Positions are relative to the run origin: add info.origin to get + // paragraph coordinates (fallback runs carry their placement in origin). + const { glyphs, positions, bounds } = info; +}); +``` + +Below, the tight ink bounds of every glyph are drawn on top of the paragraph, including the CJK glyphs resolved via font fallback. + + ## Fonts diff --git a/apps/docs/static/img/paragraph/glyph-tight-bounds.png b/apps/docs/static/img/paragraph/glyph-tight-bounds.png new file mode 100644 index 0000000000..565ade158a Binary files /dev/null and b/apps/docs/static/img/paragraph/glyph-tight-bounds.png differ diff --git a/apps/docs/static/img/paragraph/metrics-bounds.png b/apps/docs/static/img/paragraph/metrics-bounds.png new file mode 100644 index 0000000000..080d750f4a Binary files /dev/null and b/apps/docs/static/img/paragraph/metrics-bounds.png differ diff --git a/apps/example/e2e/web.spec.ts b/apps/example/e2e/web.spec.ts index 77da6f48e1..7d208a2529 100644 --- a/apps/example/e2e/web.spec.ts +++ b/apps/example/e2e/web.spec.ts @@ -27,7 +27,15 @@ const skip: Partial> = { }; // Screens that render no canvas by design; they must still load cleanly. -const noCanvas: Screen[] = ["List", "Web", "FirstFrameEmpty"]; +// ParagraphPath and GlyphBounds show a fallback message on web since +// Paragraph.getPath() and Paragraph.extendedVisit() are native-only. +const noCanvas: Screen[] = [ + "List", + "Web", + "FirstFrameEmpty", + "ParagraphPath", + "GlyphBounds", +]; // Expected uncaught errors, e.g. from examples that demo error handling. const allowedErrors: Partial> = { diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 140748f79b..dba6f8cd75 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -3208,88 +3208,88 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 11b010917f5f15150b2c015abddef1573d2bb05d - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1 RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0 RCTSwiftUI: a6c7271c39098bf00dbdad8f8ed997a59bbfbe44 - RCTSwiftUIWrapper: 5ec163e8fde163d3fba714a992b50a266e1ece37 + RCTSwiftUIWrapper: ff9098ccf7727e58218f2f8ea110349863f43438 RCTTypeSafety: 27927d0ca04e419ed9467578b3e6297e37210b5c React: 4bc1f928568ad4bcfd147260f907b4ea5873a03b React-callinvoker: 87f8728235a0dc62e9dc19b3851c829d9347d015 - React-Core: 19e0183e28d7a6613ecacebd7525fe6650efa3b6 - React-CoreModules: 73cc86f2a0ff84b93d6325073ad2e4874d21ad40 - React-cxxreact: 4bf734645c77c9b86e2f3e933e0411cf2f14d1ba + React-Core: 76bed73b02821e5630e7f2cb2e82432ee964695d + React-CoreModules: 752dbfdaeb096658aa0adc4a03ba6214815a08df + React-cxxreact: b6798528aa601c6db66e6adc7e2da2b059c8be74 React-debug: 8978deb306f6f38c28b5091e52b0ac9f942b157e - React-defaultsnativemodule: 724eb9ec388d494f1e2057d83355ee8fe6f1d780 - React-domnativemodule: 9068f41092f725acd09950233d2847364c731947 - React-Fabric: 945cc8abf08d9d0966acef605bffce7b501c49d9 - React-FabricComponents: 4c4ad6f0d16c964a68f945e029505e2eeec6654b - React-FabricImage: a8b628fd98db21b9f8588e06f14a9194dda11b40 - React-featureflags: 0937601c1af1cc125851ec5bbf4654285d47a3e7 - React-featureflagsnativemodule: ac1a3e0353e1a6e15411b17ed6c7122adb0468a4 - React-graphics: cca521e06463608be46207a4aa160f8a7f725f8b - React-hermes: ec50b9fcea2c3bfdd42f8cec845eac3f35888572 - React-idlecallbacksnativemodule: effcae5b7b4473211adb154aaa321d5d9e2fbcc9 - React-ImageManager: b38459e538f1840fa5c3e7612a4bcb0029a3c366 - React-intersectionobservernativemodule: 8d33366661971200cf2e151727f6fe007b62ae7b - React-jserrorhandler: f94c688a0dbe2e045b91b992722b92e97d56f77f - React-jsi: 3216c876cd4c571a57909e22d77c8fd9530aa067 - React-jsiexecutor: 475563c0042841a85930a455d3199f6b1483a5fe - React-jsinspector: bc484fb32bf1b9fed80afe8793e614eba4f7b39e - React-jsinspectorcdp: 5a574d1d35016968a67e78e6b8a7917473ffbb77 - React-jsinspectornetwork: dce3a5a1351b527ee8c28ad4a8bdd211507e1a45 - React-jsinspectortracing: 65f6b166bd67e5adc31eba027e1570bacf7a3cc7 - React-jsitooling: d5463f5489a31640b0fa0ec4e31566ca8aa86c13 - React-jsitracing: 3c7fc18821aba64855acb8658aa857ca6a7fddf6 - React-logger: 6ac901f5c7f7321d2be1a40b203bccc2e23411e3 - React-Mapbuffer: 2e0e7cc5b7064eaed9c8b8afc3a87621cb7ef5cd - React-microtasksnativemodule: dd4d33b251b57e5027c572c6d0b45cbfbcfaa386 - react-native-safe-area-context: 54d812805f3c4e08a4580ad086cbde1d8780c2e4 - react-native-skia: 2c1a5775d1fdf527cfd7ec08bab9c390f5ea7cbe - React-NativeModulesApple: 7f2f2fed3f6c858889eb61d09941be965d52df58 - React-networking: 43e5e6773ac2ca2a93261a1388fed269c9fce092 + React-defaultsnativemodule: 682b77ef4acfb298017be15f4f93c1d998deb174 + React-domnativemodule: 4c4b44f7eb68dbc3a2218db088bef318a7302017 + React-Fabric: b6f82a4d8498ce4475586f71ca8397a771fe292d + React-FabricComponents: c8695f4b11918a127c4560d66f7d3fdb01a17986 + React-FabricImage: d64f48830f63830e8ffaaf69fa487116856fbbf1 + React-featureflags: 2a46b229903e906d33dbaf9207ce57c59306c369 + React-featureflagsnativemodule: cba6c0814051a0934f8bcee4a436ee2a6bcc9754 + React-graphics: 3d0435051e1ab8904d065f8ffbe981a9fc202841 + React-hermes: 32fc9c231c1aa5c2fcfe851b0d19ee9269f88f4c + React-idlecallbacksnativemodule: f8ee42581795c4844d97147596bcc2d824c0f188 + React-ImageManager: e8f7377ef0585fd2df05559a17e01a03e187d5cf + React-intersectionobservernativemodule: b1bea12ca29accdd2eda60c87605a6030b894eb9 + React-jserrorhandler: 1a86df895b4eaf4e771abe8cf34cbb26d821f771 + React-jsi: adf8527fec197ad9d0480cc5b8945eb56de627f0 + React-jsiexecutor: 315fa2f879b43e3a9ca08f5f4b733472f7e4e8a4 + React-jsinspector: b4fd1933666bcb2549b566b40656c1e45e9d7632 + React-jsinspectorcdp: 80141710f2668e5b8f00417298d9b76b4abf90fa + React-jsinspectornetwork: 1d3ea717dbbec316cd8c21a0af53928a7bf74901 + React-jsinspectortracing: 4ce745374d4b2bfbd164cce9f8de8383d3d818a0 + React-jsitooling: fc4ac4c3b1f3f9f7fedf0c777c6ff3f244f568bd + React-jsitracing: bff08a6faeef4a9bd286487da191f5e5329e21a9 + React-logger: b8483fa08e0d62e430c76d864309d90576ca2f68 + React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83 + React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0 + react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 + react-native-skia: 82260e6ce556ff33eb29360905eb775383d01a8c + React-NativeModulesApple: a2c3d2cbec893956a5b3e4060322db2984fff75b + React-networking: 3f98bd96893a294376e7e03730947a08d474c380 React-oscompat: 80166b66da22e7af7fad94474e9997bd52d4c8c6 - React-perflogger: 63c90e0d8c24df87ffa14dad01aeafc352847dd0 - React-performancecdpmetrics: 5a9b81c08f75045635127d626440d9ada01e774b - React-performancetimeline: 31cebfff69ec9174b3fb54b0606fcb12ef91cbad + React-perflogger: d6797918d2b1031e91a9d8f5e7fdd2c8728fb390 + React-performancecdpmetrics: 5570be61e2f97c4741c5d432c91570e8e5a39892 + React-performancetimeline: 5763499ae1991fc18dcf416e340ce7bc829bb298 React-RCTActionSheet: 3bd5f5db9f983cf38d51bb9a7a198e2ebea94821 - React-RCTAnimation: 346865a809fa5132f6c594c8b376c6cf46b44e88 - React-RCTAppDelegate: b2d1e0d3663c987f49f45094883b9e36fcbf0181 - React-RCTBlob: 74759ebb7ff9077d19f60c301782c1f8c3eb2813 - React-RCTFabric: 7b4b14dad21ca99333ebcbc0bf5c205647a315a8 - React-RCTFBReactNativeSpec: 39151968adb68b8c59f29a8bd4223d4d7780a793 - React-RCTImage: 60763f56e8a5e45d861d7c4777e428bb820ec52a - React-RCTLinking: 52aee78b0b3163167c7fcf58f80a42943c03a056 - React-RCTNetwork: f5e1e8ae5eff6982efff6289b06ec0a76d0a6ac2 - React-RCTRuntime: 0e99199322afd372e74b95ae5c58f4e074cc2855 - React-RCTSettings: 298bb40d3412bf32e0b4f0797e48416b0b7278a1 - React-RCTText: dfb74800e27d792d1188fa975a3b9807c3362e3e - React-RCTVibration: ffe5fd4f50a835e353a3b6869eb005dab11eea44 + React-RCTAnimation: 46a9978f27dc434dbeed16afa7b82619b690a9af + React-RCTAppDelegate: 62ecd60a2b2a8cae26ce6a066bfa59cfde97af01 + React-RCTBlob: 8285c859513023ee3cc8c806d9b59d4da078c4ba + React-RCTFabric: 05ed09347e938de985052f791a6a0698816d5761 + React-RCTFBReactNativeSpec: 83ba579fca9a51e774ac32578ef5dd3262edd7e2 + React-RCTImage: a5364d0f098692cfbf5bef1e8a63e7712ecb14b7 + React-RCTLinking: 34b63b0aa0e92d30f5d7aa2c255a8f95fa75ee8f + React-RCTNetwork: 1ef88b7a5310b8f915d3556b5b247def113191ed + React-RCTRuntime: ed29cf68a46782fec891e5afe1d8d758ca6ccd9b + React-RCTSettings: 2c45623d6c0f30851a123f621eb9d32298bcbb0c + React-RCTText: 0ee70f5dc18004b4d81b2c214267c6cbec058587 + React-RCTVibration: 88557e21e7cc3fe76b5b174cba28ff45c6def997 React-rendererconsistency: d280314a3e7f0097152f89e815b4de821c2be8b9 - React-renderercss: 8a1a346f3665fd5ea7a7be7b3b9f95d4743e1180 - React-rendererdebug: af74afdfb3d6c5382ebab35562efd8eb9e690473 - React-RuntimeApple: 06e33d291e72fd0c73ac47046c3536d77d5aeedd - React-RuntimeCore: 99273d2af072062eb07f0b2d2d4a0f2de697ea14 - React-runtimeexecutor: 2063c03c18810ee57939d138142e6493333360ef - React-RuntimeHermes: 2253a7f4c8d56b449230b330b0b15383ed4b3df4 - React-runtimescheduler: ff37ac6720a943da91645c06274282ac46b71f23 - React-timing: 831d7e081ba4c332ca5cccf389b88e363f13f2b4 - React-utils: 25db6c17598c4fed22b5956d7551bb8bddf1f95b - React-webperformancenativemodule: 57e41e6193cfb815bde0b5534bef68673f1270eb - ReactAppDependencyProvider: bfb12ead469222b022a2024f32aba47ce50de512 - ReactCodegen: 9ca1bd49eee1eccf6e427e406d2163f49e9c48c0 - ReactCommon: 05ad684db7d88e194272ae26baddf6300e30b8b7 - ReactNativeHost: e7e0a518b0120f0070b3e1f13c7006d3e0e8ee13 - ReactTestApp-DevSupport: 6994b53b5b81139a8ce63e0776c726c95de079a1 + React-renderercss: f8cbf83d95c2c2bbf893d37fe50c73f046584411 + React-rendererdebug: 37216ddfcd38e49d1e92bf9052ea4bc9d7b932e5 + React-RuntimeApple: 1c0e7cb8e1c2c5775585afcaaa666ec151629a8d + React-RuntimeCore: 925fe2ca24cf8e6ed87586dbb92827306b93b83f + React-runtimeexecutor: 962dae024f6df760d029512a7d99e3f73d570935 + React-RuntimeHermes: 19a7c59ec1bc9908516f0bbc29b49425f6ec64ba + React-runtimescheduler: 62f21127cd97f4d8f164eee5150d3ce53dd36f66 + React-timing: 8757bf6fb96227c264f2d1609f4ba5c68217b8ce + React-utils: 8ab26781c2f5c2f7fafb2022c8ab39d39f231b80 + React-webperformancenativemodule: 7953b7fe519f76fa595111fe18ff3d5de131bfe9 + ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 + ReactCodegen: b8e56b780fffe6edd6405be0af4a1e3049a937f7 + ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae + ReactNativeHost: eef98ec49b55d88ad4cabf5a4378a12b42b551ee + ReactTestApp-DevSupport: ea18f446cff64b6c9a3e28788600c82ecf51bde6 ReactTestApp-Resources: 1bd9ff10e4c24f2ad87101a32023721ae923bccf - RNGestureHandler: 77eecab5fd636666ca73a55bb61e2f1a685b7e84 - RNReanimated: d1a7a4c20eefc371e062990ce1debeaff4f1b9be - RNScreens: b2a5c76af24a02a2fd71bfce42780fdd9c79cc6d - RNSVG: ea9cbf6dcdbebdfff5822b0ad9311bbc4510a0b7 - RNWorklets: 5f6e5664c1819eac103ca75cc2f36191f55aa110 + RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 + RNReanimated: c26dfcd831add485c2ed93de9d7bfb90b035eeaa + RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415 + RNSVG: 11354d28dd6cb71a59570b68c91ba6772a2d781d + RNWorklets: b89b501d37972e6419d6f87effe41d6d76157648 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: 5bd0956bf9cb16f75101e78b5e852c7577bc5a45 + Yoga: 5456bb010373068fc92221140921b09d126b116e PODFILE CHECKSUM: dca89d921c9f2a2d3d405a5fca0bfb60b30b5022 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/apps/example/src/Examples/API/GlyphBounds.tsx b/apps/example/src/Examples/API/GlyphBounds.tsx new file mode 100644 index 0000000000..4a0a61462a --- /dev/null +++ b/apps/example/src/Examples/API/GlyphBounds.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useMemo } from "react"; +import { + Platform, + StyleSheet, + Text, + View, + useWindowDimensions, +} from "react-native"; +import type { + Glyph, + SkFont, + SkPoint, + SkRect, +} from "@shopify/react-native-skia"; +import { + Canvas, + Glyphs, + Group, + Path, + RoundedRect, + Skia, + useFonts, + vec, +} from "@shopify/react-native-skia"; +import type { SharedValue } from "react-native-reanimated"; +import { + Easing, + useDerivedValue, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +const PADDING = 32; +const PALETTE = ["#e4667e", "#f5a623", "#61bea2", "#5b8def"]; +// Timeline: center-out reveal, stroke hands over to the fill, short hold, +// then every glyph drops off the canvas under gravity. +const REVEAL_END = 0.45; +const FILL_END = 0.6; +const DROP_START = 0.72; + +// Deterministic pseudo-random in [0, 1) so each glyph falls its own way. +const shuffle = (index: number) => { + "worklet"; + const x = Math.sin((index + 1) * 12.9898) * 43758.5453; + return x - Math.floor(x); +}; + +interface GlyphItem { + // Tight ink bounds of the glyph, in paragraph coordinates. + rect: SkRect; + // Glyph id and baseline position for the Glyphs component. + glyph: Glyph; + // The resolved font of the run (the fallback font, if any). + font: SkFont; + center: SkPoint; +} + +interface FallingGlyphProps { + item: GlyphItem; + index: number; + count: number; + dropHeight: number; + progress: SharedValue; +} + +const FallingGlyph = ({ + item, + index, + count, + dropHeight, + progress, +}: FallingGlyphProps) => { + // The box pops in when the center-out path reveal reaches its glyph. The + // reveal overshoots slightly so the outermost boxes still reach full + // opacity by the end of the sweep. + const boxOpacity = useDerivedValue(() => { + const reveal = Math.min(1, progress.value / REVEAL_END) * 1.2; + const middle = (count - 1) / 2; + const distance = Math.abs(index - middle) / Math.max(1, middle); + return Math.min(1, Math.max(0, (reveal - distance) * 6)); + }); + const glyphOpacity = useDerivedValue(() => + Math.min( + 1, + Math.max(0, (progress.value - REVEAL_END) / (FILL_END - REVEAL_END)) + ) + ); + const transform = useDerivedValue(() => { + const drop = Math.max(0, (progress.value - DROP_START) / (1 - DROP_START)); + const rnd = shuffle(index); + // Staggered release, then a free fall with a little drift and spin. + const t = Math.max(0, drop - rnd * 0.3) / 0.7; + return [ + { translateX: (rnd - 0.5) * 120 * t }, + { translateY: dropHeight * 1.3 * t * t }, + { rotate: (rnd - 0.5) * 3 * t }, + ]; + }); + return ( + + + + + ); +}; + +const GlyphBoundsDemo = () => { + const { width, height } = useWindowDimensions(); + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withRepeat( + withTiming(1, { duration: 8000, easing: Easing.linear }), + -1, + false + ); + }, [progress]); + + const customFontMgr = useFonts({ + "Roboto": [ + require("../../Tests/assets/Roboto-Medium.ttf"), + require("../../Tests/assets/Roboto-Regular.ttf"), + ], + "Noto Sans SC": [require("../../Tests/assets/NotoSansSC-Regular.otf")], + }); + + const layoutWidth = width - PADDING * 2; + const layout = useMemo(() => { + if (customFontMgr === null) { + return null; + } + const builder = Skia.ParagraphBuilder.Make({}, customFontMgr); + builder.pushStyle({ + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 32, + color: Skia.Color("#1e1e24"), + }); + builder.addText( + "Skia knows the exact ink bounds of every glyph, even 你好 shaped via font fallback" + ); + const paragraph = builder.build(); + paragraph.layout(layoutWidth); + // extendedVisit() exposes the exact glyph layout that gets painted: the + // resolved font, glyph ids, positions and per-glyph tight ink bounds. + // Keeping each glyph separate lets us animate them individually. + const items: GlyphItem[] = []; + paragraph.extendedVisit((_lineNumber, info) => { + if (info === null) { + // end of line + return; + } + for (let i = 0; i < info.glyphs.length; i++) { + const bounds = info.bounds[i]; + if (bounds.width === 0 || bounds.height === 0) { + // whitespace has no ink + continue; + } + // positions are relative to the run origin, bounds to the glyph + // origin: adding all three yields paragraph coordinates. + const x = info.origin.x + info.positions[i].x; + const y = info.origin.y + info.positions[i].y; + const rect = Skia.XYWHRect( + x + bounds.x, + y + bounds.y, + bounds.width, + bounds.height + ); + items.push({ + rect, + glyph: { id: info.glyphs[i], pos: vec(x, y) }, + font: info.font, + center: vec(rect.x + rect.width / 2, rect.y + rect.height / 2), + }); + } + }); + // getPath() turns the same laid out glyphs into an SkPath used for the + // trim reveal below. + const path = Skia.Path.Make(); + paragraph.getLineMetrics().forEach(({ lineNumber }) => { + const line = paragraph.getPath(lineNumber); + if (line) { + path.addPath(line); + } + }); + return { path, items, height: paragraph.getHeight() }; + }, [customFontMgr, layoutWidth]); + + // The stroke reveals the text from the center of the path outwards, going + // from trim (0.5, 0.5) to (0, 1), then hands over to the fill. + const start = useDerivedValue( + () => 0.5 * (1 - Math.min(1, progress.value / REVEAL_END)) + ); + const end = useDerivedValue( + () => 0.5 * (1 + Math.min(1, progress.value / REVEAL_END)) + ); + const strokeOpacity = useDerivedValue( + () => + 1 - + Math.min( + 1, + Math.max(0, (progress.value - REVEAL_END) / (FILL_END - REVEAL_END)) + ) + ); + + if (layout === null) { + return null; + } + return ( + + + {layout.items.map((item, index) => ( + + ))} + + + + ); +}; + +export const GlyphBounds = () => { + if (Platform.OS === "web") { + return ( + + + Paragraph.extendedVisit() is not implemented on React Native Web. + + + ); + } + return ; +}; + +const styles = StyleSheet.create({ + fallback: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: PADDING, + }, +}); diff --git a/apps/example/src/Examples/API/List.tsx b/apps/example/src/Examples/API/List.tsx index b72e613ae8..305fa8f203 100644 --- a/apps/example/src/Examples/API/List.tsx +++ b/apps/example/src/Examples/API/List.tsx @@ -34,6 +34,14 @@ export const examples = [ screen: "Paragraphs2", title: "📚 Text & Paragraphs 2", }, + { + screen: "ParagraphPath", + title: "✍️ Paragraph to Path", + }, + { + screen: "GlyphBounds", + title: "🔠 Glyph Bounds", + }, { screen: "Clipping", title: "✂️ & 🎭 Clipping & Masking", diff --git a/apps/example/src/Examples/API/ParagraphPath.tsx b/apps/example/src/Examples/API/ParagraphPath.tsx new file mode 100644 index 0000000000..44cc94b131 --- /dev/null +++ b/apps/example/src/Examples/API/ParagraphPath.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useMemo } from "react"; +import { + Platform, + ScrollView, + StyleSheet, + Text, + View, + useWindowDimensions, +} from "react-native"; +import { + Canvas, + FontWeight, + Group, + LinearGradient, + Path, + Skia, + TextAlign, + useFonts, + vec, +} from "@shopify/react-native-skia"; +import { + Easing, + useDerivedValue, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +const PADDING = 32; +const PALETTE = ["#e4667e", "#f5a623", "#61bea2", "#5b8def"]; + +const ParagraphPathDemo = () => { + const { width } = useWindowDimensions(); + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = withRepeat( + withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.cubic) }), + -1, + true + ); + }, [progress]); + + const customFontMgr = useFonts({ + Roboto: [ + require("../../Tests/assets/Roboto-Medium.ttf"), + require("../../Tests/assets/Roboto-Regular.ttf"), + ], + }); + + const contentWidth = width - PADDING * 2; + const layout = useMemo(() => { + if (customFontMgr === null) { + return null; + } + // The paragraph style is baked into the path: the centered line breaks + // below come from the paragraph layout, not from manual positioning. + const builder = Skia.ParagraphBuilder.Make( + { textAlign: TextAlign.Center }, + customFontMgr + ); + builder.pushStyle({ + fontFamilies: ["Roboto"], + fontSize: 48, + fontStyle: { weight: FontWeight.Bold }, + color: Skia.Color("black"), + }); + builder.addText("Can it be done in React Native?"); + const paragraph = builder.build(); + paragraph.layout(contentWidth); + // getPath() converts the glyphs of a laid out line into an SkPath, with + // all font fallbacks already applied. Merge the lines into a single path. + const path = Skia.Path.Make(); + paragraph.getLineMetrics().forEach(({ lineNumber }) => { + const line = paragraph.getPath(lineNumber); + if (line) { + path.addPath(line); + } + }); + return { path, height: paragraph.getHeight() }; + }, [customFontMgr, contentWidth]); + + // The stroke reveals the text from the center of the path outwards, going + // from trim (0.5, 0.5) to (0, 1); the fill fades in once it completes. + const start = useDerivedValue( + () => 0.5 * (1 - Math.min(1, progress.value / 0.7)) + ); + const end = useDerivedValue( + () => 0.5 * (1 + Math.min(1, progress.value / 0.7)) + ); + const fillOpacity = useDerivedValue(() => + Math.max(0, (progress.value - 0.7) / 0.3) + ); + // The stroke hands over to the fill: at the end only the fill remains. + const strokeOpacity = useDerivedValue(() => 1 - fillOpacity.value); + + if (layout === null) { + return null; + } + return ( + + + + + + + + + + + + + ); +}; + +export const ParagraphPath = () => { + if (Platform.OS === "web") { + return ( + + Paragraph.getPath() is not implemented on React Native Web. + + ); + } + return ; +}; + +const styles = StyleSheet.create({ + fallback: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: PADDING, + }, +}); diff --git a/apps/example/src/Examples/API/Routes.ts b/apps/example/src/Examples/API/Routes.ts index 169e13443c..e69add788b 100644 --- a/apps/example/src/Examples/API/Routes.ts +++ b/apps/example/src/Examples/API/Routes.ts @@ -27,6 +27,8 @@ export type Routes = { IconsExample: undefined; Paragraphs: undefined; Paragraphs2: undefined; + ParagraphPath: undefined; + GlyphBounds: undefined; Skottie: undefined; StressTest: undefined; StressTest2: undefined; diff --git a/apps/example/src/Examples/API/WebMemory.tsx b/apps/example/src/Examples/API/WebMemory.tsx index 27690475f2..d994f2a330 100644 --- a/apps/example/src/Examples/API/WebMemory.tsx +++ b/apps/example/src/Examples/API/WebMemory.tsx @@ -130,8 +130,8 @@ export const WebMemory = () => { 196 animated dots allocate short-lived CanvasKit WASM objects on every frame. This used to leak ~4 MB/s until the page crashed; the renderer - now disposes them, so the malloc high-water mark should stay flat. If - it climbs steadily, a disposal regression has been introduced. + now disposes them, so the malloc high-water mark should stay flat. If it + climbs steadily, a disposal regression has been introduced. {Array.from({ length: GRID * GRID }, (_, i) => ( diff --git a/apps/example/src/Examples/API/index.tsx b/apps/example/src/Examples/API/index.tsx index 80267b04f3..6f10aa05f8 100644 --- a/apps/example/src/Examples/API/index.tsx +++ b/apps/example/src/Examples/API/index.tsx @@ -30,6 +30,8 @@ import { FontMgr } from "./FontMgr"; import { AnimatedImages } from "./AnimatedImages"; import { Paragraphs } from "./Paragraphs"; import { Paragraphs2 } from "./Paragraphs2"; +import { ParagraphPath } from "./ParagraphPath"; +import { GlyphBounds } from "./GlyphBounds"; import { Skottie } from "./Skottie"; import { StressTest } from "./StressTest"; import { StressTest2 } from "./StressTest2"; @@ -107,6 +109,20 @@ export const API = () => { title: "📚 Text & Paragraphs 2", }} /> + + = { ImageLoading: "image-loading", Paragraphs: "paragraphs", Paragraphs2: "paragraphs2", + ParagraphPath: "paragraph-path", + GlyphBounds: "glyph-bounds", Clipping: "clipping", Touch: "touch", Snapshot: "snapshot", diff --git a/packages/skia/cpp/api/JsiSkParagraph.h b/packages/skia/cpp/api/JsiSkParagraph.h index 3194109308..2657296ac9 100644 --- a/packages/skia/cpp/api/JsiSkParagraph.h +++ b/packages/skia/cpp/api/JsiSkParagraph.h @@ -8,6 +8,7 @@ #include #include "JsiSkCanvas.h" +#include "JsiSkFont.h" #include "JsiSkHostObjects.h" #include "JsiSkPath.h" #include "JsiSkRect.h" @@ -141,6 +142,117 @@ class JsiSkParagraph return returnValue; } + JSI_HOST_FUNCTION(getPath) { + auto lineNumber = + static_cast(getArgumentAsNumber(runtime, arguments, count, 0)); + auto paragraph = getObject(); + // Paragraph::getPath does not bounds-check the line number. + if (lineNumber < 0 || + static_cast(lineNumber) >= paragraph->lineNumber()) { + return jsi::Value::null(); + } + // Paragraph::getPath resets its path builder after every visual run, so + // for lines shaped as multiple runs (e.g. through font fallback) it only + // returns the glyphs of the last run. Build the path from the per-glyph + // data exposed by extendedVisit instead. + SkPathBuilder builder; + paragraph->extendedVisit( + [lineNumber, &builder]( + int visitedLine, const para::Paragraph::ExtendedVisitorInfo *info) { + if (visitedLine != lineNumber || info == nullptr) { + return; + } + struct Rec { + SkPathBuilder *builder; + SkPoint origin; + const SkPoint *pos; + } rec = {&builder, info->origin, info->positions}; + info->font.getPaths( + {info->glyphs, static_cast(info->count)}, + [](const SkPath *src, const SkMatrix &mx, void *ctx) { + auto *rec = static_cast(ctx); + if (src != nullptr) { + SkMatrix total = mx; + total.postTranslate(rec->origin.fX + rec->pos->fX, + rec->origin.fY + rec->pos->fY); + rec->builder->addPath(*src, total); + } + rec->pos += 1; + }, + &rec); + }); + SkPath path = builder.detach(); + auto hostObjectInstance = + std::make_shared(getContext(), std::move(path)); + return JSI_CREATE_HOST_OBJECT_WITH_MEMORY_PRESSURE( + runtime, hostObjectInstance, getContext()); + } + + JSI_HOST_FUNCTION(extendedVisit) { + auto visitorObject = getArgumentAsFunction(runtime, arguments, count, 0); + auto visitor = visitorObject.asFunction(runtime); + auto context = getContext(); + getObject()->extendedVisit( + [&runtime, &visitor, &context]( + int lineNumber, const para::Paragraph::ExtendedVisitorInfo *info) { + if (info == nullptr) { + // Signals the end of the line + visitor.call(runtime, static_cast(lineNumber), + jsi::Value::null()); + return; + } + auto value = jsi::Object(runtime); + + auto fontInstance = std::make_shared(context, info->font); + value.setProperty(runtime, "font", + JSI_CREATE_HOST_OBJECT_WITH_MEMORY_PRESSURE( + runtime, fontInstance, context)); + + auto origin = jsi::Object(runtime); + origin.setProperty(runtime, "x", + static_cast(info->origin.x())); + origin.setProperty(runtime, "y", + static_cast(info->origin.y())); + value.setProperty(runtime, "origin", origin); + + auto advance = jsi::Object(runtime); + advance.setProperty(runtime, "width", + static_cast(info->advance.width())); + advance.setProperty(runtime, "height", + static_cast(info->advance.height())); + value.setProperty(runtime, "advance", advance); + + auto size = info->count; + auto glyphs = jsi::Array(runtime, size); + auto positions = jsi::Array(runtime, size); + auto bounds = jsi::Array(runtime, size); + auto utf8Starts = jsi::Array(runtime, size); + for (int i = 0; i < size; ++i) { + glyphs.setValueAtIndex(runtime, i, + static_cast(info->glyphs[i])); + auto position = jsi::Object(runtime); + position.setProperty(runtime, "x", + static_cast(info->positions[i].x())); + position.setProperty(runtime, "y", + static_cast(info->positions[i].y())); + positions.setValueAtIndex(runtime, i, position); + bounds.setValueAtIndex( + runtime, i, + JsiSkRect::toValue(runtime, context, info->bounds[i])); + utf8Starts.setValueAtIndex( + runtime, i, static_cast(info->utf8Starts[i])); + } + value.setProperty(runtime, "glyphs", glyphs); + value.setProperty(runtime, "positions", positions); + value.setProperty(runtime, "bounds", bounds); + value.setProperty(runtime, "utf8Starts", utf8Starts); + value.setProperty(runtime, "flags", static_cast(info->flags)); + + visitor.call(runtime, static_cast(lineNumber), value); + }); + return jsi::Value::undefined(); + } + JSI_HOST_FUNCTION(getRectsForPlaceholders) { std::vector placeholderInfos = getObject()->getRectsForPlaceholders(); @@ -169,6 +281,8 @@ class JsiSkParagraph getGlyphPositionAtCoordinate), JSI_EXPORT_FUNC(JsiSkParagraph, getRectsForRange), JSI_EXPORT_FUNC(JsiSkParagraph, getLineMetrics), + JSI_EXPORT_FUNC(JsiSkParagraph, getPath), + JSI_EXPORT_FUNC(JsiSkParagraph, extendedVisit), JSI_EXPORT_FUNC(JsiSkParagraph, dispose)) size_t getMemoryPressure() const override { return 1024 * 1024; } diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-ios.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-ios.png new file mode 100644 index 0000000000..2aa3a4ade0 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-bounding-boxes-ios.png differ diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-tight-bounds-ios.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-tight-bounds-ios.png new file mode 100644 index 0000000000..cb6e1a7579 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-glyph-tight-bounds-ios.png differ diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-ios.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-ios.png new file mode 100644 index 0000000000..9be029331f Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-ios.png differ diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-node.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-node.png new file mode 100644 index 0000000000..4787b28989 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-metrics-bounds-node.png differ diff --git a/packages/skia/src/__tests__/snapshots/paragraph/paragraph-path-segment-ios.png b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-path-segment-ios.png new file mode 100644 index 0000000000..94266b2358 Binary files /dev/null and b/packages/skia/src/__tests__/snapshots/paragraph/paragraph-path-segment-ios.png differ diff --git a/packages/skia/src/__tests__/snapshots/platform-buffer.png b/packages/skia/src/__tests__/snapshots/platform-buffer.png index d932bd16a5..e54a481770 100644 Binary files a/packages/skia/src/__tests__/snapshots/platform-buffer.png and b/packages/skia/src/__tests__/snapshots/platform-buffer.png differ diff --git a/packages/skia/src/renderer/__tests__/e2e/ParagraphGlyphs.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/ParagraphGlyphs.spec.tsx new file mode 100644 index 0000000000..256fe4eddf --- /dev/null +++ b/packages/skia/src/renderer/__tests__/e2e/ParagraphGlyphs.spec.tsx @@ -0,0 +1,562 @@ +import { resolveFile, surface } from "../setup"; +import { checkImage, itRunsE2eOnly } from "../../../__tests__/setup"; +import { PaintStyle } from "../../../skia/types"; + +const RobotoRegular = Array.from( + resolveFile("skia/__tests__/assets/Roboto-Regular.ttf") +); + +const NotoSansSC = Array.from( + resolveFile("skia/__tests__/assets/NotoSansSC-Regular.otf") +); + +// getPath() and extendedVisit() are implemented on iOS/Android only +// (throwNotImplementedOnRNWeb on React Native Web), so these tests only run +// against a device (E2E). They cover the use case from +// https://github.com/Shopify/react-native-skia/issues/3493: measuring text +// with font fallback while getting bounds tight to the actual glyphs. +describe("Paragraph glyph-level APIs", () => { + describe("getPath", () => { + itRunsE2eOnly( + "should return ink bounds that adapt to the glyphs, unlike getRectsForRange", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const measure = (text: string) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + const path = paragraph.getPath(0)!; + const tightBounds = path.computeTightBounds(); + return { + inkHeight: tightBounds.height, + inkWidth: tightBounds.width, + rectHeight: paragraph.getRectsForRange(0, text.length)[0] + .height, + }; + }; + return { + hello: measure("Hello"), + typography: measure("Typography"), + }; + }, + { RobotoRegular } + ); + // The path is tight to the glyph ink: "Hello" has no descenders and + // measures smaller than "Typography"... + expect(result.hello.inkHeight).toBeGreaterThan(0); + expect(result.hello.inkHeight).toBeLessThan( + result.typography.inkHeight + ); + // ...while getRectsForRange returns the same metrics-based height for + // both strings, larger than the ink bounds. + expect(result.hello.rectHeight).toBeCloseTo( + result.typography.rectHeight, + 3 + ); + expect(result.hello.inkHeight).toBeLessThan(result.hello.rectHeight); + expect(result.typography.inkHeight).toBeLessThan( + result.typography.rectHeight + ); + } + ); + + itRunsE2eOnly( + "should include the glyphs resolved through font fallback", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const roboto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.NotoSansSC)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(roboto, "Roboto"); + provider.registerFont(noto, "Noto Sans SC"); + const measure = (text: string) => { + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 24, + }); + builder.addText(text); + const paragraph = builder.build(); + paragraph.layout(512); + const tightBounds = paragraph.getPath(0)!.computeTightBounds(); + return { width: tightBounds.width, height: tightBounds.height }; + }; + return { + mixed: measure("Hello你好"), + latinOnly: measure("Hello"), + }; + }, + { RobotoRegular, NotoSansSC } + ); + // The path contains the ink of the CJK glyphs shaped through the + // fallback font, so it is wider than the latin-only text. + expect(result.mixed.width).toBeGreaterThan(result.latinOnly.width); + expect(result.mixed.height).toBeGreaterThanOrEqual( + result.latinOnly.height + ); + } + ); + + itRunsE2eOnly("should return a path for every line", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText("First line\nSecond line"); + const paragraph = builder.build(); + paragraph.layout(512); + const toPlain = (r: { + x: number; + y: number; + width: number; + height: number; + }) => ({ x: r.x, y: r.y, width: r.width, height: r.height }); + return { + line0: toPlain(paragraph.getPath(0)!.computeTightBounds()), + line1: toPlain(paragraph.getPath(1)!.computeTightBounds()), + }; + }, + { RobotoRegular } + ); + expect(result.line0.width).toBeGreaterThan(0); + expect(result.line1.width).toBeGreaterThan(0); + // The second line is laid out below the first one. + expect(result.line1.y).toBeGreaterThan(result.line0.y); + }); + + itRunsE2eOnly( + "should return null for an out of bounds line number", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(512); + return { + negative: paragraph.getPath(-1) === null, + tooLarge: paragraph.getPath(1) === null, + valid: paragraph.getPath(0) !== null, + }; + }, + { RobotoRegular } + ); + expect(result.negative).toBe(true); + expect(result.tooLarge).toBe(true); + expect(result.valid).toBe(true); + } + ); + }); + + describe("extendedVisit", () => { + itRunsE2eOnly( + "should visit one run per resolved font with per-glyph layout info", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const roboto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.NotoSansSC)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(roboto, "Roboto"); + provider.registerFont(noto, "Noto Sans SC"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 24, + }); + // "Hello" shapes with Roboto, "你好" with the fallback font: two + // runs on a single line. + builder.addText("Hello你好"); + const paragraph = builder.build(); + paragraph.layout(512); + const runs: Array<{ + lineNumber: number; + fontSize: number; + glyphs: number[]; + positions: Array<{ x: number; y: number }>; + bounds: Array<{ + x: number; + y: number; + width: number; + height: number; + }>; + utf8Starts: number[]; + originX: number; + originY: number; + advanceWidth: number; + flags: number; + }> = []; + let endOfLines = 0; + paragraph.extendedVisit((lineNumber, info) => { + if (info === null) { + endOfLines++; + return; + } + runs.push({ + lineNumber, + fontSize: info.font.getSize(), + glyphs: info.glyphs, + positions: info.positions.map((p) => ({ x: p.x, y: p.y })), + bounds: info.bounds.map((b) => ({ + x: b.x, + y: b.y, + width: b.width, + height: b.height, + })), + utf8Starts: info.utf8Starts, + originX: info.origin.x, + originY: info.origin.y, + advanceWidth: info.advance.width, + flags: info.flags, + }); + }); + return { runs, endOfLines }; + }, + { RobotoRegular, NotoSansSC } + ); + // A single line, ended once, shaped as two runs (Roboto + fallback). + expect(result.endOfLines).toBe(1); + expect(result.runs).toHaveLength(2); + const [latin, cjk] = result.runs; + expect(latin.lineNumber).toBe(0); + expect(cjk.lineNumber).toBe(0); + // The resolved font is exposed for each run. + expect(latin.fontSize).toBe(24); + expect(cjk.fontSize).toBe(24); + // "Hello" is 5 glyphs, "你好" is 2 glyphs, all resolved (no .notdef). + expect(latin.glyphs).toHaveLength(5); + expect(cjk.glyphs).toHaveLength(2); + expect(latin.glyphs.every((g) => g !== 0)).toBe(true); + expect(cjk.glyphs.every((g) => g !== 0)).toBe(true); + for (const run of result.runs) { + // Every glyph comes with a position, tight ink bounds and the + // utf8 index of its cluster. + expect(run.positions).toHaveLength(run.glyphs.length); + expect(run.bounds).toHaveLength(run.glyphs.length); + expect(run.utf8Starts).toHaveLength(run.glyphs.length); + expect(run.bounds.every((b) => b.width > 0 && b.height > 0)).toBe( + true + ); + // "Hello你好" is 11 bytes in utf8 (5 + 2 * 3). + expect(run.utf8Starts.every((u) => u >= 0 && u < 11)).toBe(true); + expect(run.advanceWidth).toBeGreaterThan(0); + expect(run.flags).toBe(0); + } + // The glyph ink bounds adapt to the actual glyphs: "Hello" has no + // descenders, so its tallest glyph ink is smaller than the + // metrics-based line height (~28 for Roboto at fontSize 24). + const maxLatinInk = Math.max(...latin.bounds.map((b) => b.height)); + expect(maxLatinInk).toBeGreaterThan(0); + expect(maxLatinInk).toBeLessThan(24); + // The CJK run starts after the latin run. Glyph positions are + // relative to the run origin, so the run placement is carried by + // origin.x (positions[0].x is 0 for a run shaped through fallback). + expect(cjk.originX + cjk.positions[0].x).toBeGreaterThan( + latin.originX + latin.positions[0].x + ); + } + ); + + itRunsE2eOnly( + "should signal the end of each line with a null info", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 24, + }); + builder.addText("Hello\nWorld"); + const paragraph = builder.build(); + paragraph.layout(512); + const visits: Array<{ lineNumber: number; isEndOfLine: boolean }> = + []; + paragraph.extendedVisit((lineNumber, info) => { + visits.push({ lineNumber, isEndOfLine: info === null }); + }); + return visits; + }, + { RobotoRegular } + ); + // Two lines: each visited with at least one run followed by a null. + expect(result.filter((v) => v.isEndOfLine)).toHaveLength(2); + expect( + result.filter((v) => !v.isEndOfLine && v.lineNumber === 0).length + ).toBeGreaterThan(0); + expect( + result.filter((v) => !v.isEndOfLine && v.lineNumber === 1).length + ).toBeGreaterThan(0); + // Line 0 is visited before line 1, each terminated by its own null. + const lineNumbers = result.map((v) => v.lineNumber); + expect(lineNumbers).toEqual([...lineNumbers].sort((a, b) => a - b)); + } + ); + + itRunsE2eOnly( + "should produce glyph bounds that match the line path", + async () => { + const result = await surface.eval( + (Skia, ctx) => { + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 72, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(512); + // The union of the positioned glyph ink bounds... + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + paragraph.extendedVisit((_lineNumber, info) => { + if (info === null) { + return; + } + for (let i = 0; i < info.glyphs.length; i++) { + const x = info.origin.x + info.positions[i].x; + const y = info.origin.y + info.positions[i].y; + left = Math.min(left, x + info.bounds[i].x); + top = Math.min(top, y + info.bounds[i].y); + right = Math.max( + right, + x + info.bounds[i].x + info.bounds[i].width + ); + bottom = Math.max( + bottom, + y + info.bounds[i].y + info.bounds[i].height + ); + } + }); + // ...should match the tight bounds of the line path. + const pathBounds = paragraph.getPath(0)!.computeTightBounds(); + return { + union: { left, top, right, bottom }, + path: { + left: pathBounds.x, + top: pathBounds.y, + right: pathBounds.x + pathBounds.width, + bottom: pathBounds.y + pathBounds.height, + }, + }; + }, + { RobotoRegular } + ); + // Both express the ink bounds of the same glyphs in paragraph + // coordinates; allow a small tolerance for path conversion rounding. + expect(Math.abs(result.union.left - result.path.left)).toBeLessThan(2); + expect(Math.abs(result.union.top - result.path.top)).toBeLessThan(2); + expect(Math.abs(result.union.right - result.path.right)).toBeLessThan( + 2 + ); + expect(Math.abs(result.union.bottom - result.path.bottom)).toBeLessThan( + 2 + ); + } + ); + }); + + describe("drawing", () => { + itRunsE2eOnly("should draw a segment of the paragraph path", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + canvas.drawColor(Skia.Color("white")); + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 72, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(ctx.width); + // Draw only the first half of the text outline. + const path = paragraph.getPath(0)!; + const segment = path.trim(0, 0.5, false)!; + const paint = Skia.Paint(); + paint.setColor(Skia.Color("black")); + paint.setStyle(ctx.Stroke); + paint.setStrokeWidth(2); + canvas.drawPath(segment, paint); + }, + { + RobotoRegular, + Stroke: PaintStyle.Stroke, + width: surface.width, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-path-segment-${surface.OS}.png`, + { maxPixelDiff: 300 } + ); + }); + + itRunsE2eOnly( + "should draw the tight bounding box of each glyph on top of the paragraph", + async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + canvas.drawColor(Skia.Color("white")); + const roboto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const noto = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.NotoSansSC)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(roboto, "Roboto"); + provider.registerFont(noto, "Noto Sans SC"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto", "Noto Sans SC"], + fontSize: 54, + }); + builder.addText("Hello你好"); + const paragraph = builder.build(); + paragraph.layout(ctx.width); + paragraph.paint(canvas, 0, 0); + // Draw the tight ink bounds of every glyph on top, including the + // glyphs shaped through the fallback font. + const paint = Skia.Paint(); + paint.setColor(Skia.Color("magenta")); + paint.setStyle(ctx.Stroke); + paint.setStrokeWidth(1); + paragraph.extendedVisit((_lineNumber, info) => { + if (info === null) { + return; + } + for (let i = 0; i < info.glyphs.length; i++) { + canvas.drawRect( + Skia.XYWHRect( + info.origin.x + info.positions[i].x + info.bounds[i].x, + info.origin.y + info.positions[i].y + info.bounds[i].y, + info.bounds[i].width, + info.bounds[i].height + ), + paint + ); + } + }); + }, + { + RobotoRegular, + NotoSansSC, + Stroke: PaintStyle.Stroke, + width: surface.width, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-glyph-tight-bounds-${surface.OS}.png` + ); + } + ); + + // This one only uses the pre-existing Paragraph API, so it also runs on + // node/web: the metrics-based bounds are available on every platform. + it("should draw the metrics-based (non-tight) bounding box on top of the paragraph", async () => { + const img = await surface.drawOffscreen( + (Skia, canvas, ctx) => { + canvas.drawColor(Skia.Color("white")); + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData( + Skia.Data.fromBytes(new Uint8Array(ctx.RobotoRegular)) + )!; + const provider = Skia.TypefaceFontProvider.Make(); + provider.registerFont(typeface, "Roboto"); + const builder = Skia.ParagraphBuilder.Make({}, provider); + builder.pushStyle({ + color: Skia.Color("black"), + fontFamilies: ["Roboto"], + fontSize: 72, + }); + builder.addText("Hello"); + const paragraph = builder.build(); + paragraph.layout(ctx.width); + paragraph.paint(canvas, 0, 0); + // The rects returned by getRectsForRange reserve the full + // ascent/descent of the font: the box is visibly taller than the + // glyph ink ("Hello" has no descenders). + const paint = Skia.Paint(); + paint.setColor(Skia.Color("magenta")); + paint.setStyle(ctx.Stroke); + paint.setStrokeWidth(1); + for (const rect of paragraph.getRectsForRange(0, 5)) { + canvas.drawRect(rect, paint); + } + }, + { + RobotoRegular, + Stroke: PaintStyle.Stroke, + width: surface.width, + } + ); + checkImage( + img, + `snapshots/paragraph/paragraph-metrics-bounds-${surface.OS}.png` + ); + }); + }); +}); diff --git a/packages/skia/src/renderer/__tests__/e2e/setup/Paragraph.ts b/packages/skia/src/renderer/__tests__/e2e/setup/Paragraph.ts index f2dcecc792..7e60b87707 100644 --- a/packages/skia/src/renderer/__tests__/e2e/setup/Paragraph.ts +++ b/packages/skia/src/renderer/__tests__/e2e/setup/Paragraph.ts @@ -1,4 +1,9 @@ -import type { SkParagraph, Skia, SkCanvas } from "../../../../skia/types"; +import type { + ParagraphExtendedVisitor, + SkParagraph, + Skia, + SkCanvas, +} from "../../../../skia/types"; import type { EvalContext } from "../../setup"; import { SkiaObject } from "./SkiaObject"; @@ -48,6 +53,12 @@ export class ParagraphAsset getRectsForPlaceholders() { return this.instance.getRectsForPlaceholders(); } + getPath(lineNumber: number) { + return this.instance.getPath(lineNumber); + } + extendedVisit(visitor: ParagraphExtendedVisitor) { + this.instance.extendedVisit(visitor); + } __typename__ = "Paragraph" as const; [Symbol.dispose](): void { diff --git a/packages/skia/src/skia/types/Paragraph/Paragraph.ts b/packages/skia/src/skia/types/Paragraph/Paragraph.ts index f2014dfd6c..834571cf4d 100644 --- a/packages/skia/src/skia/types/Paragraph/Paragraph.ts +++ b/packages/skia/src/skia/types/Paragraph/Paragraph.ts @@ -1,6 +1,10 @@ import type { SkCanvas } from "../Canvas"; +import type { SkFont } from "../Font"; import type { SkJSIInstance } from "../JsiInstance"; +import type { SkPath } from "../Path"; +import type { SkPoint } from "../Point"; import type { SkRect } from "../Rect"; +import type { SkSize } from "../Size"; import type { TextDirection } from "./ParagraphStyle"; @@ -40,6 +44,47 @@ export interface SkRectWithDirection { direction: TextDirection; } +/** + * Information about a run of glyphs passed to the visitor of + * `SkParagraph.extendedVisit`. All glyphs in a run share the same font: for + * text shaped through font fallback, each fallback font produces its own + * run(s). + */ +export interface ParagraphVisitorInfo { + /** The font used to shape this run (the resolved fallback font, if any). */ + font: SkFont; + /** Origin of the run within the paragraph (baseline corrected). */ + origin: SkPoint; + /** Advance of the run. */ + advance: SkSize; + /** The glyph ids of the run. */ + glyphs: number[]; + /** + * The position of each glyph, relative to the run `origin`. Add `origin` + * to obtain paragraph coordinates: for a run shaped through font fallback, + * the run placement is carried by `origin`, not by the positions. + */ + positions: SkPoint[]; + /** + * The tight ink bounds of each glyph, relative to the glyph's origin. + * Combine with `positions` to compute the exact painted bounds of the run. + */ + bounds: SkRect[]; + /** The UTF-8 text index of the cluster each glyph belongs to. */ + utf8Starts: number[]; + /** Reserved flags (currently always 0). */ + flags: number; +} + +/** + * Visitor for `SkParagraph.extendedVisit`. Called once per run of glyphs; + * `info` is null to signal the end of a line. + */ +export type ParagraphExtendedVisitor = ( + lineNumber: number, + info: ParagraphVisitorInfo | null +) => void; + export interface SkParagraph extends SkJSIInstance<"Paragraph"> { /** * Calculates the position of the the glyphs in the paragraph @@ -112,4 +157,26 @@ export interface SkParagraph extends SkJSIInstance<"Paragraph"> { * to the paragraph. */ getRectsForPlaceholders(): SkRectWithDirection[]; + /** + * Converts the glyphs of the given line into an SkPath, with all font + * fallbacks already applied. Use `computeTightBounds()` on the resulting + * path to measure the exact ink bounds of the rendered text (as opposed to + * the font-metrics based bounds returned by `getRectsForRange`). + * Note that color glyphs (e.g. emojis) cannot be converted to a path and + * are skipped. Returns null if the line number is out of bounds. + * This method requires the layout method to have been called first. + * Not implemented on React Native Web. + * @param lineNumber The line number (zero indexed, see `getLineMetrics`) + */ + getPath(lineNumber: number): SkPath | null; + /** + * Visits the laid out paragraph, calling the visitor once for every run of + * glyphs with the resolved font, glyph ids, positions and per-glyph tight + * ink bounds. The visitor is called with a null info to signal the end of + * each line. This exposes the exact layout that would be painted. + * This method requires the layout method to have been called first. + * Not implemented on React Native Web. + * @param visitor Called once per glyph run, and once per line end + */ + extendedVisit(visitor: ParagraphExtendedVisitor): void; } diff --git a/packages/skia/src/skia/web/JsiSkParagraph.ts b/packages/skia/src/skia/web/JsiSkParagraph.ts index ca3daeba49..aaaa8ec04a 100644 --- a/packages/skia/src/skia/web/JsiSkParagraph.ts +++ b/packages/skia/src/skia/web/JsiSkParagraph.ts @@ -4,10 +4,12 @@ import type { SkRect, SkRectWithDirection, SkParagraph, + SkPath, LineMetrics, + ParagraphExtendedVisitor, } from "../types"; -import { HostObject } from "./Host"; +import { HostObject, throwNotImplementedOnRNWeb } from "./Host"; import type { JsiSkCanvas } from "./JsiSkCanvas"; import { JsiSkRect } from "./JsiSkRect"; @@ -64,4 +66,10 @@ export class JsiSkParagraph getLineMetrics(): LineMetrics[] { return this.ref.getLineMetrics(); } + getPath(_lineNumber: number): SkPath | null { + return throwNotImplementedOnRNWeb(); + } + extendedVisit(_visitor: ParagraphExtendedVisitor): void { + return throwNotImplementedOnRNWeb(); + } }