Last active
April 16, 2025 13:17
-
-
Save binrebin/f3dad29956eb8dcb760a38ce86a9553b to your computer and use it in GitHub Desktop.
Simplest markdown parser for android jetpack compose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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