Skip to content

Instantly share code, notes, and snippets.

@binrebin
Last active April 16, 2025 13:17
Show Gist options
  • Save binrebin/f3dad29956eb8dcb760a38ce86a9553b to your computer and use it in GitHub Desktop.
Save binrebin/f3dad29956eb8dcb760a38ce86a9553b to your computer and use it in GitHub Desktop.
Simplest markdown parser for android jetpack compose.
@Composable
fun MarkdownPreview(text: String) {
val context = LocalContext.current
val annotatedString = parseMarkdownToAnnotatedString(text)
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedString,
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures { tapOffsetPosition ->
val layoutResult = textLayoutResult ?: return@detectTapGestures
val position = layoutResult.getOffsetForPosition(tapOffsetPosition)
annotatedString
.getStringAnnotations(start = position, end = position)
.firstOrNull { it.tag == "URL" }
?.let { annotation ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item))
context.startActivity(intent)
}
}
},
onTextLayout = { result ->
textLayoutResult = result
}
)
}
@Composable
fun parseMarkdownToAnnotatedString(markdown: String): AnnotatedString {
// Define regex patterns
val linkRegex = """\[(.*?)\]\((.*?)\)""".toRegex()
val boldRegex = """\*\*(.*?)\*\*""".toRegex()
val italicRegex = """\*(.*?)\*""".toRegex()
val codeBlockRegex = """```([\s\S]*?)```""".toRegex()
val inlineCodeRegex = """`(.*?)`""".toRegex()
val headingRegex = """^(#{1,2})\s*(.*)""".toRegex(RegexOption.MULTILINE)
val listRegex = """^- (.*)""".toRegex(RegexOption.MULTILINE)
val blockquoteRegex = """^>\s+(.*)""".toRegex(RegexOption.MULTILINE) // NEW
val tokens = mutableListOf<MarkdownToken>()
fun addMatches(pattern: Regex, type: TokenType, groupCount: Int) {
pattern.findAll(markdown).forEach { result ->
val matchedGroups = (1..groupCount).map { i -> result.groups[i]?.value ?: "" }
tokens += MarkdownToken(
type = type,
start = result.range.first,
end = result.range.last + 1,
groups = matchedGroups
)
}
}
// Collect tokens for each pattern
addMatches(codeBlockRegex, TokenType.CODE_BLOCK, 1)
addMatches(inlineCodeRegex, TokenType.INLINE_CODE, 1)
addMatches(linkRegex, TokenType.LINK, 2)
addMatches(boldRegex, TokenType.BOLD, 1)
addMatches(italicRegex, TokenType.ITALIC, 1)
addMatches(headingRegex, TokenType.HEADING, 2)
addMatches(listRegex, TokenType.LIST, 1)
addMatches(blockquoteRegex, TokenType.BLOCKQUOTE, 1)
tokens.sortBy { it.start }
val builder = AnnotatedString.Builder()
var currentIndex = 0
fun appendGapText(upTo: Int) {
if (currentIndex < upTo) {
builder.append(markdown.substring(currentIndex, upTo))
currentIndex = upTo
}
}
for (token in tokens) {
if (token.start < currentIndex) continue
appendGapText(token.start)
when (token.type) {
TokenType.CODE_BLOCK -> {
val codeContent = token.groups[0].trim()
val styleStart = builder.length
builder.append(codeContent)
builder.addStyle(
SpanStyle(
background = Color(0xFFEFEFEF),
color = Color(0xFF333333),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace
),
styleStart,
builder.length
)
}
TokenType.INLINE_CODE -> {
val codeContent = token.groups[0]
val styleStart = builder.length
builder.append(codeContent)
builder.addStyle(
SpanStyle(
background = Color.LightGray,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
),
styleStart,
builder.length
)
}
TokenType.LINK -> {
val (linkText, linkUrl) = token.groups
val styleStart = builder.length
builder.append(linkText)
builder.addStyle(
SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
),
styleStart,
builder.length
)
// Attach a string annotation with tag = "URL"
builder.addStringAnnotation(
tag = "URL",
annotation = linkUrl,
start = styleStart,
end = builder.length
)
}
TokenType.BOLD -> {
val boldContent = token.groups[0]
val styleStart = builder.length
builder.append(boldContent)
builder.addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
styleStart,
builder.length
)
}
TokenType.ITALIC -> {
val italicContent = token.groups[0]
val styleStart = builder.length
builder.append(italicContent)
builder.addStyle(
SpanStyle(fontStyle = FontStyle.Italic),
styleStart,
builder.length
)
}
TokenType.HEADING -> {
val headingLevel = token.groups[0].length // # or ##
val headingText = token.groups[1]
val styleStart = builder.length
builder.append(headingText)
builder.addStyle(
SpanStyle(
fontSize = if (headingLevel == 1) 26.sp else 22.sp,
fontWeight = FontWeight.Bold,
color = if (headingLevel == 1) Color(0xFFA75CF2) else Color(0xFF48D883)
),
styleStart,
builder.length
)
}
TokenType.LIST -> {
val listItem = token.groups[0]
builder.append("• $listItem\n")
}
TokenType.BLOCKQUOTE -> {
val quoteText = token.groups[0]
val styleStart = builder.length
builder.append(quoteText)
builder.addStyle(
SpanStyle(
background = Color(0xFFE0E0E0),
fontStyle = FontStyle.Italic
),
styleStart,
builder.length
)
builder.append("\n")
}
}
currentIndex = token.end
}
appendGapText(markdown.length)
return builder.toAnnotatedString()
}
private data class MarkdownToken(
val type: TokenType,
val start: Int,
val end: Int,
val groups: List<String>
)
private enum class TokenType {
CODE_BLOCK,
INLINE_CODE,
LINK,
BOLD,
ITALIC,
HEADING,
LIST,
BLOCKQUOTE
}
// -------------- Usage EXAMPLE -----------------
@Composable
fun MyAppPolicyCheck(){
val myMarkdoentext = """
# Welcome to MyApp
## Second heading
Welcome to the **Policy Check** feature of MyAPp.
*This is an italic text example.*
[Visit Andra Pradesh Police Website](http://www.appolice.gov.in/)
- First bullet point
- Second bullet point
> This is a blockquote example.
```
fun helloWorld() {
println("Hello, world!")
}
```
Inline code example: `val x = 10`
""".trimIndent()
Card(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
) {
MarkdownPreview(text = myMarkdoentext)
}
}
}
// No Copyright Bijukumar Narayanan
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment