Skip to content

Commit 0296d87

Browse files
authored
Merge pull request #63 from openai/codex/rtl-compatibility
Add opt-in RTL compatibility for markdown rendering
2 parents 9adb862 + 5cadd63 commit 0296d87

11 files changed

Lines changed: 547 additions & 68 deletions

File tree

richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
package com.halilibo.richtext.markdown
22

3+
import androidx.compose.foundation.layout.IntrinsicSize
4+
import androidx.compose.foundation.layout.fillMaxWidth
5+
import androidx.compose.foundation.layout.width
36
import androidx.compose.foundation.text.BasicText
47
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.CompositionLocalProvider
59
import androidx.compose.runtime.remember
610
import androidx.compose.ui.Modifier
711
import androidx.compose.ui.semantics.heading
812
import androidx.compose.ui.semantics.semantics
13+
import com.halilibo.richtext.markdown.rtl.LocalCompatibilityTextAlignOverride
14+
import com.halilibo.richtext.markdown.rtl.firstStrongTextDirectionInFirstLine
15+
import com.halilibo.richtext.markdown.rtl.toCompatibilityTextAlign
16+
import com.halilibo.richtext.markdown.rtl.toCompatibilityTextDirection
917
import com.halilibo.richtext.markdown.node.AstBlockNodeType
1018
import com.halilibo.richtext.markdown.node.AstBlockQuote
1119
import com.halilibo.richtext.markdown.node.AstDocument
@@ -27,6 +35,7 @@ import com.halilibo.richtext.markdown.node.AstTableRow
2735
import com.halilibo.richtext.markdown.node.AstText
2836
import com.halilibo.richtext.markdown.node.AstThematicBreak
2937
import com.halilibo.richtext.markdown.node.AstUnorderedList
38+
import com.halilibo.richtext.ui.BasicRichText
3039
import com.halilibo.richtext.ui.BlockQuote
3140
import com.halilibo.richtext.ui.CodeBlock
3241
import com.halilibo.richtext.ui.FormattedList
@@ -84,15 +93,31 @@ public fun RichTextScope.BasicMarkdown(
8493
richTextDecorations: RichTextDecorations = RichTextDecorations(),
8594
astBlockNodeComposer: AstBlockNodeComposer? = null,
8695
) {
87-
RecursiveRenderMarkdownAst(
88-
astNode = astNode,
89-
contentOverride = contentOverride,
90-
inlineContentOverride = inlineContentOverride,
91-
richTextRenderOptions = richTextRenderOptions,
92-
richTextDecorations = richTextDecorations,
93-
markdownAnimationState = remember { MarkdownAnimationState() },
94-
astNodeComposer = astBlockNodeComposer,
95-
)
96+
val markdownAnimationState = remember { MarkdownAnimationState() }
97+
98+
if (richTextRenderOptions.enableRtlCompatibility && astNode.type is AstDocument) {
99+
BasicRichText(modifier = Modifier.width(IntrinsicSize.Max)) {
100+
RecursiveRenderMarkdownAst(
101+
astNode = astNode,
102+
contentOverride = contentOverride,
103+
inlineContentOverride = inlineContentOverride,
104+
richTextRenderOptions = richTextRenderOptions,
105+
richTextDecorations = richTextDecorations,
106+
markdownAnimationState = markdownAnimationState,
107+
astNodeComposer = astBlockNodeComposer,
108+
)
109+
}
110+
} else {
111+
RecursiveRenderMarkdownAst(
112+
astNode = astNode,
113+
contentOverride = contentOverride,
114+
inlineContentOverride = inlineContentOverride,
115+
richTextRenderOptions = richTextRenderOptions,
116+
richTextDecorations = richTextDecorations,
117+
markdownAnimationState = markdownAnimationState,
118+
astNodeComposer = astBlockNodeComposer,
119+
)
120+
}
96121
}
97122

98123
/**
@@ -238,46 +263,59 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer {
238263
markdownAnimationState: MarkdownAnimationState,
239264
visitChildren: @Composable (AstNode) -> Unit
240265
) {
266+
val compatibilityDirection = remember(astNode) {
267+
if (richTextRenderOptions.enableRtlCompatibility) {
268+
when (astNode.type) {
269+
is AstBlockQuote,
270+
is AstIndentedCodeBlock,
271+
is AstFencedCodeBlock -> astNode
272+
is AstUnorderedList,
273+
is AstOrderedList -> astNode.links.firstChild
274+
else -> null
275+
}?.firstStrongTextDirectionInFirstLine()
276+
} else {
277+
null
278+
}
279+
}
280+
241281
when (val astNodeType = astNode.type) {
242282
is AstDocument -> visitChildren(astNode)
243283
is AstBlockQuote -> {
244284
BlockQuote(
245285
markdownAnimationState = markdownAnimationState,
246286
richTextRenderOptions = richTextRenderOptions,
287+
gutterDirection = compatibilityDirection,
247288
) {
248-
visitChildren(astNode)
249-
}
250-
}
251-
252-
is AstUnorderedList -> {
253-
FormattedList(
254-
listType = Unordered,
255-
markdownAnimationState = markdownAnimationState,
256-
richTextRenderOptions = richTextRenderOptions,
257-
items = astNode.filterChildrenType<AstListItem>().toList()
258-
) { astListItem ->
259-
// if this list item has no child, it should at least emit a single pixel layout.
260-
if (astListItem.links.firstChild == null) {
261-
BasicText("")
262-
} else {
263-
visitChildren(astListItem)
289+
CompositionLocalProvider(
290+
LocalCompatibilityTextAlignOverride provides compatibilityDirection.toCompatibilityTextAlign(),
291+
) {
292+
visitChildren(astNode)
264293
}
265294
}
266295
}
267296

297+
is AstUnorderedList,
268298
is AstOrderedList -> {
299+
val items = remember(astNode) {
300+
astNode.childrenSequence().toList()
301+
}
269302
FormattedList(
270-
listType = Ordered,
303+
listType = if (astNodeType is AstOrderedList) Ordered else Unordered,
271304
markdownAnimationState = markdownAnimationState,
272305
richTextRenderOptions = richTextRenderOptions,
273-
items = astNode.childrenSequence().toList(),
274-
startIndex = astNodeType.startNumber - 1,
306+
items = items,
307+
startIndex = if (astNodeType is AstOrderedList) astNodeType.startNumber - 1 else 0,
308+
markerDirection = compatibilityDirection,
275309
) { astListItem ->
276-
// if this list item has no child, it should at least emit a single pixel layout.
277-
if (astListItem.links.firstChild == null) {
278-
BasicText("")
279-
} else {
280-
visitChildren(astListItem)
310+
CompositionLocalProvider(
311+
LocalCompatibilityTextAlignOverride provides compatibilityDirection.toCompatibilityTextAlign(),
312+
) {
313+
// if this list item has no child, it should at least emit a single pixel layout.
314+
if (astListItem.links.firstChild == null) {
315+
BasicText("")
316+
} else {
317+
visitChildren(astListItem)
318+
}
281319
}
282320
}
283321
}
@@ -307,6 +345,9 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer {
307345
text = astNodeType.literal.trim(),
308346
markdownAnimationState = markdownAnimationState,
309347
richTextRenderOptions = richTextRenderOptions,
348+
modifier = if (compatibilityDirection != null) Modifier.fillMaxWidth() else Modifier,
349+
textDirection = compatibilityDirection.toCompatibilityTextDirection(),
350+
textAlign = compatibilityDirection.toCompatibilityTextAlign(),
310351
)
311352
}
312353

@@ -315,6 +356,9 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer {
315356
text = astNodeType.literal.trim(),
316357
markdownAnimationState = markdownAnimationState,
317358
richTextRenderOptions = richTextRenderOptions,
359+
modifier = if (compatibilityDirection != null) Modifier.fillMaxWidth() else Modifier,
360+
textDirection = compatibilityDirection.toCompatibilityTextDirection(),
361+
textAlign = compatibilityDirection.toCompatibilityTextAlign(),
318362
)
319363
}
320364

richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package com.halilibo.richtext.markdown
22

33
import androidx.compose.foundation.layout.fillMaxWidth
44
import androidx.compose.runtime.Composable
5-
import androidx.compose.runtime.MutableState
65
import androidx.compose.runtime.remember
76
import androidx.compose.ui.Modifier
87
import androidx.compose.ui.layout.ContentScale
98
import androidx.compose.ui.unit.IntSize
109
import androidx.compose.ui.unit.dp
10+
import com.halilibo.richtext.markdown.rtl.LocalCompatibilityTextAlignOverride
11+
import com.halilibo.richtext.markdown.rtl.firstStrongTextDirection
12+
import com.halilibo.richtext.markdown.rtl.toCompatibilityTextAlign
13+
import com.halilibo.richtext.markdown.rtl.toCompatibilityTextDirection
1114
import com.halilibo.richtext.markdown.node.AstBlockQuote
1215
import com.halilibo.richtext.markdown.node.AstCode
1316
import com.halilibo.richtext.markdown.node.AstEmphasis
@@ -69,17 +72,34 @@ internal fun RichTextScope.MarkdownRichText(
6972
modifier: Modifier = Modifier,
7073
) {
7174
// Assume that only RichText nodes reside below this level.
72-
val richText = remember(astNode) {
75+
val enableCompatibilityDirection = richTextRenderOptions.enableRtlCompatibility &&
76+
(astNode.type is AstParagraph || astNode.type is AstHeading)
77+
val richText = remember(astNode, inlineContentOverride) {
7378
computeRichTextString(astNode, inlineContentOverride)
7479
}
80+
val compatibilityDirection = if (enableCompatibilityDirection) {
81+
remember(richText) {
82+
richText.text.firstStrongTextDirection(stopAtLineBreak = true)
83+
}
84+
} else {
85+
null
86+
}
87+
val textDirection = compatibilityDirection.toCompatibilityTextDirection()
7588

7689
Text(
7790
text = richText,
78-
modifier = modifier,
91+
modifier = if (compatibilityDirection != null) {
92+
modifier.fillMaxWidth()
93+
} else {
94+
modifier
95+
},
7996
isLeafText = astNode.isLastInTree(),
8097
renderOptions = richTextRenderOptions,
8198
sharedAnimationState = markdownAnimationState,
8299
decorations = richTextDecorations,
100+
textAlign = LocalCompatibilityTextAlignOverride.current
101+
?: compatibilityDirection.toCompatibilityTextAlign(),
102+
textDirection = textDirection,
83103
)
84104
}
85105

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.halilibo.richtext.markdown.rtl
2+
3+
import androidx.compose.runtime.compositionLocalOf
4+
import androidx.compose.ui.text.style.TextAlign
5+
import androidx.compose.ui.text.style.TextDirection
6+
import com.halilibo.richtext.markdown.childrenSequence
7+
import com.halilibo.richtext.markdown.node.AstCode
8+
import com.halilibo.richtext.markdown.node.AstFencedCodeBlock
9+
import com.halilibo.richtext.markdown.node.AstHardLineBreak
10+
import com.halilibo.richtext.markdown.node.AstHtmlBlock
11+
import com.halilibo.richtext.markdown.node.AstHtmlInline
12+
import com.halilibo.richtext.markdown.node.AstIndentedCodeBlock
13+
import com.halilibo.richtext.markdown.node.AstNode
14+
import com.halilibo.richtext.markdown.node.AstSoftLineBreak
15+
import com.halilibo.richtext.markdown.node.AstText
16+
import kotlin.text.CharDirectionality
17+
18+
/**
19+
* Lets list and quote containers override paragraph alignment without forcing a different
20+
* direction-detection rule for the text inside the item.
21+
*/
22+
internal val LocalCompatibilityTextAlignOverride = compositionLocalOf<TextAlign?> { null }
23+
24+
/**
25+
* Returns the first strong bidi direction found before the first rendered line break in this node.
26+
*
27+
* Examples:
28+
* - `AstText("...שלום")` returns [TextDirection.Rtl].
29+
* - `AstText("...123")` returns `null`.
30+
*
31+
* Edge case:
32+
* - A paragraph like `123\nhello` still returns `null`, because compatibility mode treats the
33+
* first visible line as decisive for block alignment and ignores stronger text on later lines.
34+
*/
35+
internal fun AstNode.firstStrongTextDirectionInFirstLine(): TextDirection? {
36+
var lineEnded = false
37+
38+
fun AstNode.findFirstStrongTextDirection(): TextDirection? {
39+
if (lineEnded) return null
40+
41+
if (type is AstSoftLineBreak || type is AstHardLineBreak) {
42+
lineEnded = true
43+
return null
44+
}
45+
46+
val literal = when (type) {
47+
is AstText -> type.literal
48+
is AstCode -> type.literal
49+
is AstIndentedCodeBlock -> type.literal
50+
is AstFencedCodeBlock -> type.literal
51+
is AstHtmlBlock -> type.literal
52+
is AstHtmlInline -> type.literal
53+
else -> null
54+
}
55+
val direction = literal?.firstStrongTextDirection(
56+
stopAtLineBreak = true,
57+
ignoreHtmlTags = type is AstHtmlBlock || type is AstHtmlInline,
58+
onLineBreak = { lineEnded = true },
59+
)
60+
61+
return direction ?: childrenSequence().firstNotNullOfOrNull { child ->
62+
child.findFirstStrongTextDirection()
63+
}
64+
}
65+
66+
return findFirstStrongTextDirection()
67+
}
68+
69+
internal fun TextDirection?.toCompatibilityTextAlign(): TextAlign? = when (this) {
70+
TextDirection.Ltr -> TextAlign.Left
71+
TextDirection.Rtl -> TextAlign.Right
72+
else -> null
73+
}
74+
75+
internal fun TextDirection?.toCompatibilityTextDirection(): TextDirection? = when (this) {
76+
TextDirection.Ltr -> TextDirection.ContentOrLtr
77+
TextDirection.Rtl -> TextDirection.ContentOrRtl
78+
else -> null
79+
}
80+
81+
/**
82+
* Scans text for the first strong bidi character and returns its direction.
83+
*
84+
* Examples:
85+
* - `"123 hello"` returns [TextDirection.Ltr].
86+
* - `"123 שלום"` returns [TextDirection.Rtl].
87+
*
88+
* Edge cases:
89+
* - With `stopAtLineBreak = true`, `"123\nhello"` returns `null` instead of
90+
* [TextDirection.Ltr].
91+
* - With `ignoreHtmlTags = true`, `"<b>שלום</b>"` returns [TextDirection.Rtl] rather than
92+
* treating tag characters as content.
93+
*/
94+
internal fun CharSequence.firstStrongTextDirection(
95+
stopAtLineBreak: Boolean = false,
96+
ignoreHtmlTags: Boolean = false,
97+
onLineBreak: () -> Unit = {},
98+
): TextDirection? {
99+
var insideHtmlTag = false
100+
for (char in this) {
101+
if (stopAtLineBreak && (char == '\n' || char == '\r')) {
102+
onLineBreak()
103+
return null
104+
}
105+
106+
when {
107+
ignoreHtmlTags && char == '<' -> insideHtmlTag = true
108+
ignoreHtmlTags && insideHtmlTag && char == '>' -> insideHtmlTag = false
109+
ignoreHtmlTags && insideHtmlTag -> Unit
110+
else -> when (char.directionality) {
111+
CharDirectionality.LEFT_TO_RIGHT,
112+
CharDirectionality.LEFT_TO_RIGHT_EMBEDDING,
113+
CharDirectionality.LEFT_TO_RIGHT_OVERRIDE -> return TextDirection.Ltr
114+
115+
CharDirectionality.RIGHT_TO_LEFT,
116+
CharDirectionality.RIGHT_TO_LEFT_ARABIC,
117+
CharDirectionality.RIGHT_TO_LEFT_EMBEDDING,
118+
CharDirectionality.RIGHT_TO_LEFT_OVERRIDE -> return TextDirection.Rtl
119+
120+
else -> Unit
121+
}
122+
}
123+
}
124+
125+
return null
126+
}

0 commit comments

Comments
 (0)