fix(#1263): preserve nested inline Markdown styles (#1268)

This commit is contained in:
m57 authored and GitHub committed 2026-09-25 23:05:27 +03:00
1 parent e80b00a6a7
commit 1ba1a62f31
3 files changed
+188 -56

No files matched your search

@@ -0,0 +1,33 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class MarkdownHeadingRenderingTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun headingRendersCodeInsideBold() {
composeTestRule.setContent {
MaterialTheme {
MarkdownText(
text = "## **Set `foo` now**",
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
}
}
@@ -66,33 +66,84 @@ object MarkdownInlineStyler {
return buildAnnotatedString { return buildAnnotatedString {
var i = 0 var i = 0
val src = text val src = text
while (i < src.length) { while (i < src.length) {
when { when {
// ***bold italic*** // Inline code is opaque to emphasis parsing, and delimiters must match by run length.
src.startsWith("***", i) -> { src[i] == '`' -> {
val end = src.indexOf("***", i + 3) var runEnd = i
if (end != -1) { while (runEnd < src.length && src[runEnd] == '`') runEnd++
val raw = src.substring(i + 3, end) val runLength = runEnd - i
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw var end = runEnd
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { var matchingEnd = -1
append(toAppend) while (end < src.length) {
if (src[end] == '`') {
var candidateEnd = end
while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++
if (candidateEnd - end == runLength) {
matchingEnd = end
break
} }
i = end + 3 end = candidateEnd
} else { } else {
append(src[i]) end++
i++ }
}
if (matchingEnd >= 0) {
val raw = src.substring(runEnd, matchingEnd)
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(content)
}
i = matchingEnd + runLength
} else {
append(src.substring(i, runEnd))
i = runEnd
}
}
// Combined / nested emphasis delimiters.
src.startsWith("***", i) -> {
val end = src.indexOf("***", i + 3)
if (end >= 0) {
val raw = src.substring(i + 3, end)
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(content)
}
i = end + 3
} else {
append("***")
i += 3
} }
} }
// **bold**
src.startsWith("**", i) -> { src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2) var end = src.indexOf("**", i + 2)
if (end >= 0 && src.startsWith("***", end) &&
src.substring(i + 2, end).count { it == '*' } % 2 == 1
) {
end++ // Inner italic closes with the first star of the trailing run.
}
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 2, end) val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val nested =
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { parseInlineSource(
append(toAppend) raw,
} textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) }
i = end + 2 i = end + 2
} else { } else {
append(src[i]) append(src[i])
@@ -116,16 +167,24 @@ object MarkdownInlineStyler {
} }
} }
// *italic* // *italic*; bold spans keep their styles while nested text is appended.
src.startsWith("*", i) -> { src.startsWith("*", i) -> {
val end = src.indexOf('*', i + 1) val close = src.indexOf('*', i + 1)
if (end != -1 && end > i + 1) { if (close > i + 1) {
val raw = src.substring(i + 1, end) val raw = src.substring(i + 1, close)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val italic = SpanStyle(fontStyle = FontStyle.Italic)
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { val nested =
append(toAppend) parseInlineSource(
} raw,
i = end + 1 textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
isRtl,
)
withStyle(italic) { append(nested) }
i = close + 1
} else { } else {
append(src[i]) append(src[i])
i++ i++
@@ -225,16 +284,20 @@ object MarkdownInlineStyler {
val urlEnd = src.indexOf(')', close + 2) val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) { if (urlEnd != -1) {
val label = src.substring(i + 1, close) val label = src.substring(i + 1, close)
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd) val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url)) pushLink(LinkAnnotation.Url(url))
withStyle( val labelText =
SpanStyle( parseInlineSource(
color = linkColor, label,
textDecoration = TextDecoration.Underline, textColor,
), searchQuery,
) { isCurrentMatch,
append(labelToAppend) linkColor,
highlights,
isRtl,
)
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(labelText)
} }
pop() pop()
i = urlEnd + 1 i = urlEnd + 1
@@ -248,28 +311,6 @@ object MarkdownInlineStyler {
} }
} }
// `inline code`
src.startsWith("`", i) -> {
val end = src.indexOf('`', i + 1)
if (end != -1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = textColor.copy(alpha = 0.08f),
),
) {
append(toAppend)
}
i = end + 1
} else {
append(src[i])
i++
}
}
// bare URL // bare URL
URL_PATTERN.matchAt(src, i) != null -> { URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!! val match = URL_PATTERN.matchAt(src, i)!!
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
assertEquals("Run val x = 1 here", parsed.toString()) assertEquals("Run val x = 1 here", parsed.toString())
} }
@Test
fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() {
val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("bold code and italic", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
val codeStart = parsed.indexOf("code")
assertTrue(
parsed.spanStyles.any {
it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
assertTrue(
parsed.spanStyles.any {
it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace &&
it.start <= codeStart &&
it.end >= codeStart + 4
},
)
}
@Test
fun testLinkLabel_nestedEmphasisAndCode() {
val parsed =
parseInline(
"[**bold `code`**](https://example.com)",
Color.Black,
"",
false,
Color.Blue,
DEFAULT_HIGHLIGHTS,
)
assertEquals("bold code", parsed.toString())
assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test
fun testCodeSpan_doesNotParseInnerEmphasis() {
val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("**literal**", parsed.toString())
assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
}
@Test
fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() {
val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("a ` tick and `unfinished", parsed.toString())
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
}
@Test @Test
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"