[iOS] <Text> draws a .notdef box for the Khmer two-part vowels ោ / ៅ in a custom font (TextKit 1 layout in RCTTextLayoutManager)

#58784 · open · 0 comments

View on GitHub ↗

HongMenghak

### Description On iOS, `<Text>` draws a missing-glyph (`.notdef`) box where the reordered pre-base half of the Khmer two-part vowels ោ (U+17C4) and ៅ (U+17C5) should be, when the text is set in a custom OpenType font that covers Khmer. Tested with Google Sans (SIL OFL 1.1). `ចាក់សោ នៅ` renders as `ចាក់▯សោ ▯នៅ`. The same string renders correctly, in the same app on the same device, in: - `<TextInput>` in the same custom font - `<Text>` with `fontFamily: 'System'` Only ោ and ៅ are affected. The other vowels with a pre-base part (ើ ឿ ៀ េ ែ ៃ) render correctly in the same `<Text>`. This was first reported as expo/expo#50028 and redirected here, since the layout code is React Native's. ### Still present on the latest stable (0.87.1) The linked reproducer is a plain React Native 0.87.1 app (no Expo), created with `npx @react-native-community/cli init`. I ran it as a Release build with the stock prebuilt React core, Xcode 26.6, on the iOS 26.5 simulator, and row 1 shows the boxes. It also reproduces on: - React Native 0.86.3 and 0.85.3 under Expo SDK 57 and 56 (https://github.com/HongMenghak/expo-khmer-text-repro) - Built with Xcode 26.6 and 26.2 - iOS 26.2 and iOS 18.0 simulators `RCTTextLayoutManager.mm` on `main` still uses only `NSLayoutManager`. ### Where it comes from `RCTTextLayoutManager.mm` lays out and draws `<Text>` with `NSLayoutManager` (TextKit 1), built in `_textStorageAndLayoutManagerWithAttributesString:paragraphAttributes:size:`. Laying the *same* `NSAttributedString` out through TextKit 2 (`NSTextContentStorage` + `NSTextLayoutManager`) draws the vowels correctly, with no change to the string, the font or the attributes. So what React Native builds is fine; the box only appears on the TextKit 1 path. That matches `<TextInput>` being unaffected, since UIKit's own text views use TextKit 2 by default. The underlying misbehaviour is probably TextKit 1's handling of this font's Khmer shaping (ោ is split into េ + ា and the first half is reordered in front of the consonant cluster), but React Native is where it surfaces and where it can be avoided. ### A workaround that does not work Inserting U+200D ZERO WIDTH JOINER before the vowel removes the box but blocks the ប + ា ligature, so បោះ reads as ហោះ, which is a different word. I found no string-level workaround that gives both a correct vowel and the ligature. ### The fix I am using A patch to `RCTTextLayoutManager.mm` (against 0.86.3) that builds a TextKit 2 stack when the string contains Khmer (U+1780 to U+17FF), has no attachments and does not use `adjustsFontSizeToFit`. Everything else keeps the current TextKit 1 path. ```objc NSTextContentStorage *contentStorage = [NSTextContentStorage new]; contentStorage.textStorage = textStorage; NSTextLayoutManager *textLayoutManager = [NSTextLayoutManager new]; [contentStorage addTextLayoutManager:textLayoutManager]; textLayoutManager.textContainer = textContainer; ``` The patch adds TextKit 2 implementations of measuring, drawing (including background colours and the highlight path), `getLinesForAttributedString`, the event-emitter hit test and `getRectWithAttributedString`. In our app it covers regular and bold faces, `numberOfLines` truncation, `lineHeight`, background colours, alignment and nested spans. It needs React core built from source, because the stock file ships inside the prebuilt framework. I can open a PR with it or post the full patch (about 250 added lines) if this direction is acceptable. I would also like to hear if a narrower fix is preferred. ### Steps to reproduce 1. `git clone https://github.com/HongMenghak/react-native-khmer-text-repro && cd react-native-khmer-text-repro` 2. `npm install` 3. `cd ios && bundle install && bundle exec pod install && cd ..` 4. `npx react-native run-ios` 5. Compare row 1 (`<Text>` in Google Sans) with row 2 (`<TextInput>` in the same font) and row 3 (`<Text>` in the system font). **Expected:** row 1 looks the same as row 2. **Actual:** row 1 shows `ចាក់▯សោ ▯នៅ ▯ក្រោយ ▯ដោះ▯សោ ▯បោះបង់`. The app is a stock `@react-native-community/cli init` project on 0.87.1. The only additions are the font (`assets/fonts/GoogleSans-Regular.ttf`, linked with `react-native-asset`) and `App.tsx`: ```tsx import { Text, TextInput, View } from 'react-native'; const FONT = 'GoogleSans-Regular'; const S = 'ចាក់សោ នៅ ក្រោយ ដោះសោ បោះបង់ | ដើម្បី ដែល ថ្ងៃ'; export default function App() { return ( <View style={{ flex: 1, backgroundColor: '#fff' }}> <View style={{ padding: 16, paddingTop: 72, gap: 12 }}> {/* 1. BUG: box before ោ / ៅ */} <Text style={{ fontFamily: FONT, fontSize: 22 }}>1 Text: {S}</Text> {/* 2. correct */} <TextInput editable={false} style={{ fontFamily: FONT, fontSize: 22 }} value={`2 TextInput: ${S}`} /> {/* 3. correct */} <Text style={{ fontFamily: 'System', fontSize: 22 }}>3 System: {S}</Text> </View> </View> ); } ``` ### React Native Version 0.87.1 ### Affected Platforms Runtime - iOS ### Output of `npx @react-native-community/cli info` ```text System: OS: macOS 26.6.2 CPU: (8) arm64 Apple M3 Memory: 909.78 MB / 16.00 GB Shell: version: "5.9" path: /bin/zsh Binaries: Node: version: 22.14.0 path: /usr/local/bin/node Yarn: Not Found npm: version: 10.9.2 path: /usr/local/bin/npm Watchman: Not Found Managers: CocoaPods: version: 1.16.2 path: /usr/local/bin/pod SDKs: iOS SDK: Platforms: - DriverKit 25.5 - iOS 26.5 - macOS 26.5 - tvOS 26.5 - visionOS 26.5 - watchOS 26.5 Android SDK: API Levels: - "28" - "29" - "30" - "31" - "32" - "33" - "34" - "35" - "36" Build Tools: - 30.0.3 - 33.0.1 - 34.0.0 - 35.0.0 - 36.0.0 - 36.1.0 System Images: - android-31 | Google Play ARM 64 v8a - android-34 | Google Play ARM 64 v8a - android-35 | Google Play ARM 64 v8a - android-36 | Google Play ARM 64 v8a Android NDK: Not Found IDEs: Android Studio: 2025.2 AI-252.28238.7.2523.14688667 Xcode: version: 26.6/17F113 path: /usr/bin/xcodebuild Languages: Java: version: 17.0.13 path: /usr/bin/javac Ruby: version: 3.4.7 path: /opt/homebrew/opt/ruby/bin/ruby npmPackages: "@react-native-community/cli": installed: 20.2.0 wanted: 20.2.0 react: installed: 19.2.3 wanted: 19.2.3 react-native: installed: 0.87.1 wanted: 0.87.1 react-native-macos: Not Found npmGlobalPackages: "*react-native*": Not Found Android: hermesEnabled: true newArchEnabled: true iOS: hermesEnabled: true newArchEnabled: true ``` ### Stacktrace or Logs ```text No crash and no log output. This is a rendering defect; see the screenshot below. ``` ### MANDATORY Reproducer https://github.com/HongMenghak/react-native-khmer-text-repro ### Screenshots and Videos iOS 26.5 simulator, React Native 0.87.1 (the linked reproducer). Row 1 (`<Text>`, Google Sans) shows the boxes; row 2 (`<TextInput>`, same font) and row 3 (`<Text>`, system font) are correct. <img src="https://raw.githubusercontent.com/HongMenghak/react-native-khmer-text-repro/main/screenshot-rn0871-ios265.png" width="360" alt="Row 1 shows a box before each ោ / ៅ; rows 2 and 3 are correct" />

Comments