Android: leading glyph ink clipped at RTL (Arabic) line starts - API 35 fix exists (setShiftDrawingOffsetForStartOverhang) but TextLayoutManager never sets it; existing reflection handle has zero call sites
まだ誰も着手していません。
- 主要言語
- C++
- スター
- 127k
- フォーク
- 25.3k
- 平均マージ
- 1日 23時間
- マージ済み PR(30日)
- 4
説明
Description
Description
Arabic glyphs whose ink extends past their advance width (alef-wasla ٱ, alef-madda آ, lam-alef ligatures) are clipped at the leading (right) edge — but only when the glyph's word starts a line. Mid-line instances render correctly. iOS is unaffected.
This is the same defect family as #32645 (open since 2021). We spent a multi-day investigation and can supply the full elimination matrix; summary below.
What we established
- Padding cannot fix it: tested to a 24pt ceiling on a line with 2.23pt of measured overhang ink — still clipped. The clip is internal to line layout, not box geometry.
- Every directional lever (direction:'rtl' on Text, on ancestor View, textAlign:'right', 'justify') engages the clipping path. Removing all directional styling renders the ink but loses right-flush (alignment follows component layout direction, not paragraph bidi).
- Measured ink (fontkit, shaped runs via font.layout(), 26pt): Amiri worst-case leading overhang 2.23pt; KFGQPC Hafs 1.04pt; Noto Naskh 0.49pt. Fonts with ink inside advances mask the bug — it is not rare, just font-dependent.
- 8347cc4 (0.83.2) explicitly scopes its two-pass bounds fix to unconstrained/AT_MOST text ("Text with EXACTLY width mode is unaffected") — full-width paragraphs measure EXACTLY and never receive it.
The fix that exists but is never called
Android 15 (API 35) added StaticLayout.Builder#setUseBoundsForWidth and #setShiftDrawingOffsetForStartOverhang — the latter is documented as shifting the drawing offset so start-side overhang ink renders instead of clipping. In RN 0.86.2:
- TextLayoutManager.kt has exactly ONE StaticLayout.Builder site (buildLayout, ~line 674, the funnel for both measurement and Fabric layout creation). Neither setter is applied there.
- TextLayoutManager.kt:108-118 already contains a lazily-cached reflection Method handle for setUseBoundsForWidth — with zero call sites anywhere in the tree. The plumbing exists; it was never connected.
- App-side workarounds are impossible under Fabric: with enablePreparedTextLayout active (observed enabled under Expo SDK 57), all text renders via PreparedLayoutTextView from the precomputed layout; runtime probing found reactTextViews=0 of 31 text views, so no view-level setter can reach the drawn layout.
Proposed fix (verified non-executing in-app; we could not build RN from source to device-test it):
// TextLayoutManager.kt, buildLayout(), before builder.build():
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
builder.setUseBoundsForWidth(true)
builder.setShiftDrawingOffsetForStartOverhang(true)
}
Reproducer
https://github.com/muhasabahhub/android-react-native-arabic
Environment
| react-native | 0.86.2 (new architecture / Fabric, newArchEnabled: true) |
| expo | ~57.0.15 |
| Font | Amiri Regular, bundled at assets/fonts/Amiri-Regular.ttf (SIL OFL 1.1, assets/fonts/OFL.txt) |
| Reproduces on | Android 15 (API 35) and Android 16 (API 36) |
| Does not reproduce on | iOS |
The font matters: Amiri's alif-madda has ~2.2pt of ink past its advance box at
fontSize: 26. A low-overhang face such as Noto Naskh (~0.5pt) hides the bug
without fixing it.
Screenshots
Steps to reproduce
rtl-ink-clip-repro
Minimal reproduction: Arabic leading ink is clipped at the start of an RTL line on Android.
Text 1 clips the alif-madda's leading ink at line start (Android 15+, RN 0.86/Fabric).
Text 2 renders the ink but flushes left.
iOS renders both correctly.
Repro steps
npm installnpx expo run:android— a dev build is required; this cannot be seen in Expo Go.- Look at the two tinted paragraphs. The tint is each
<Text>'s own background,
so the coloured rectangle is the view's box. - Find the word beginning with U+0622 (alif madda) at the start of the second
line of each paragraph. The console prints the first codepoint of every
rendered line viaonTextLayout, e.g.[text1-direction-rtl] line 2 starts U+0622
— use it to confirm the word is positioned correctly. - If U+0622 does not land at the start of line 2 on your screen width, adjust
FILLER_COUNT_BEFOREinApp.tsxand repeat.
Both <Text> elements are full-width with zero horizontal padding, deliberately:
any side padding widens the view's bounds and masks the clipping.
Expected
The alif-madda's madda stroke renders in full, inside the tinted box, at the
right-hand (reading-start) edge of line 2 — as it does on iOS.
Actual
- Text 1 (
style={{ direction: 'rtl' }}): the paragraph is correctly
right-aligned, and the madda stroke is cut off vertically at the tint's
right edge. The ink that overhangs the glyph's advance box falls outside
the text view's bounds and is not drawn. - Text 2 (no
directionstyle, string prefixed with U+200F RLM): the same
glyph renders complete, because the paragraph is flushed to the left and
the leading edge is no longer at the view's boundary — so the ink has room.
This is not a usable workaround: the paragraph is now left-aligned.
Text 2 is included to show that the clipping is a function of where the line
starts relative to the view's bounds, not of the glyph or the font.
Likely cause
ReactAndroid/.../views/text/TextLayoutManager.kt builds every layout at a
single StaticLayout.Builder.obtain(...) site and sets neither of the two
Android 15 APIs intended for exactly this case:
.setUseBoundsForWidth(true)
.setShiftDrawingOffsetForStartOverhang(true)
Notably, RN 0.86.2 already carries a lazily cached reflection handle for
setUseBoundsForWidth (TextLayoutManager.kt:108-118, with the comment
"Reflection is needed because some internal targets compile against an SDK
older than 35") — but it has zero call sites, and no feature flag guards
it. The API is reached for and then never used.
ReactTextView.onDraw ends in super.onDraw(canvas), i.e. the view draws the
Layout that TextView.makeNewLayout() builds for itself, so the equivalent
TextView setters at attach time are a second possible site.
Likely cause
ReactAndroid/.../views/text/TextLayoutManager.kt builds every layout at a
single StaticLayout.Builder.obtain(...) site and sets neither of the two
Android 15 APIs intended for exactly this case:
.setUseBoundsForWidth(true)
.setShiftDrawingOffsetForStartOverhang(true)
Notably, RN 0.86.2 already carries a lazily cached reflection handle for
setUseBoundsForWidth (TextLayoutManager.kt:108-118, with the comment
"Reflection is needed because some internal targets compile against an SDK
older than 35") — but it has zero call sites, and no feature flag guards
it. The API is reached for and then never used.
ReactTextView.onDraw ends in super.onDraw(canvas), i.e. the view draws the
Layout that TextView.makeNewLayout() builds for itself, so the equivalent
TextView setters at attach time are a second possible site.
React Native Version
0.86.2
Affected Platforms
Runtime - Android
Output of npx @react-native-community/cli info
System:
OS: Windows 11 10.0.26200
CPU: (22) x64 Intel(R) Core(TM) Ultra 7 155H
Memory: 2.23 GB / 15.59 GB
Binaries:
Node:
version: 24.13.1
path: C:\Program Files\nodejs\node.EXE
Yarn: Not Found
npm:
version: 11.19.0
path: C:\Program Files\nodejs\npm.CMD
Watchman: Not Found
SDKs:
Android SDK:
API Levels:
- "34"
- "35"
- "36"
Build Tools:
- 33.0.1
- 34.0.0
- 35.0.0
- 36.0.0
System Images:
- android-35 | Google Play Intel x86_64 Atom
Android NDK: Not Found
Windows SDK:
AllowDevelopmentWithoutDevLicense: Enabled
AllowAllTrustedApps: Enabled
IDEs:
Android Studio: AI-253.29346.138.2531.14876573
Visual Studio:
- 17.14.37314.3 (Visual Studio Community 2022)
Languages:
Java:
version: 23.0.1
path: C:\Program Files\Common Files\Oracle\Java\javapath\javac.EXE
Ruby: Not Found
npmPackages:
"@react-native-community/cli": Not Found
react:
installed: 19.2.3
wanted: 19.2.3
react-native:
installed: 0.86.2
wanted: 0.86.2
react-native-windows: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: Not found
newArchEnabled: Not found
iOS:
hermesEnabled: Not found
newArchEnabled: Not found
- newArchEnabled: true — set in app.json, so this is Fabric.
- Hermes is on (Expo SDK 57 default).
Stacktrace or Logs
--------- beginning of main
08-20 10:56:26.349 17466 17466 I INKFLAG : probe[requestLayout+invalidate]: sdk=36 textViews=83 reactTextViews=83
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="11/15" class=ReactTextView layout=StaticLayout w=108
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="" class=ReactTextView layout=BoringLayout w=72
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="" class=ReactTextView layout=BoringLayout w=72
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="1×" class=ReactTextView layout=StaticLayout w=43
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="لَا إِلَهَ إِلَّا ال" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="None has the right t" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="TRANSLITERATION" class=ReactTextView layout=StaticLayout w=371
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="" class=ReactTextView layout=BoringLayout w=48
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="VIRTUE & REFERENCE" class=ReactTextView layout=StaticLayout w=428
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="" class=ReactTextView layout=BoringLayout w=48
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="R" class=ReactTextView layout=StaticLayout w=31
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="T" class=ReactTextView layout=StaticLayout w=31
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.350 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.350 17466 17466 I INKFLAG : found textview, text="—" class=ReactTextView layout=StaticLayout w=36
08-20 10:56:26.350 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="ٱللَّهُ لَآ إِلَٰهَ" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="A" class=ReactTextView layout=StaticLayout w=21
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="ٱللَّهُ لَآ إِلَٰه" class=ReactTextView layout=StaticLayout w=668
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="L" class=ReactTextView layout=StaticLayout w=17
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱللَّهُ لَآ إِلَٰ" class=ReactTextView layout=StaticLayout w=558
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱلۡقَيُّومُۚ لَا ت" class=ReactTextView layout=StaticLayout w=935
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱلسَّمَٰوَٰتِ وَمَ" class=ReactTextView layout=StaticLayout w=726
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱلَّذِي يَشۡفَعُ ع" class=ReactTextView layout=StaticLayout w=697
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" يَعۡلَمُ مَا بَيۡن" class=ReactTextView layout=StaticLayout w=851
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" يُحِيطُونَ بِشَيۡء" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱلسَّمَٰوَٰتِ وَٱل" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text=" ٱلۡعَلِيُّ ٱلۡعَظِ" class=ReactTextView layout=StaticLayout w=329
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="M" class=ReactTextView layout=StaticLayout w=28
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="1×" class=ReactTextView layout=StaticLayout w=43
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="آية الكرسي" class=ReactTextView layout=StaticLayout w=984
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="Ayatul Kursi" class=ReactTextView layout=StaticLayout w=243
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
08-20 10:56:26.351 17466 17466 I INKFLAG : nudge ran: requestLayout+invalidate
08-20 10:56:26.351 17466 17466 I INKFLAG : found textview, text="Al-Baqarah · Ayah 25" class=ReactTextView layout=StaticLayout w=381
08-20 10:56:26.351 17466 17466 I INKFLAG : setters applied (useBoundsForWidth + shiftStartOverhang)
LAG : probe[setText(getText())]: DONE applied=83
MANDATORY Reproducer
https://github.com/muhasabahhub/android-react-native-arabic
Screenshots and Videos
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
調査の方向性
ReactAndroid/.../views/text/TextLayoutManager.kt から始め、特に 108~118 行付近のキャッシュされた reflection handle と、674 行付近の buildLayout 内にある StaticLayout.Builder の使用箇所を確認してください。リンクされたアラビア語の reproducer を Android 15 または 16 で実行し、その後、layout path が start-side overhang を clipping なしで処理しつつ、古い API levels および Fabric/prepared layouts での動作を維持していることを確認してください。
索引モデルが issue の本文から書いたものです。
評価
- 技術スタック
- android, kotlin, react-native
- 領域
- mobile
- issue の種類
- バグ
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 活発さ
- 活発
- 明瞭さ
- 明確に書かれている
- 初心者へのやさしさ
- 55/100