Repository navigation
Markdown
Module: com.github.re-ovo.bettertext:markdown, package me.rerere.bettertext.markdown. It includes the text module.
Renders Markdown with GFM (tables, task lists, strikethrough, autolinks), math and embedded HTML. Paragraphs, headings, lists and quotes are drawn with BetterText; code blocks, formulas, tables, images and details blocks come from the app. The module depends on neither the latex module nor Material 3.
Markdown(
content = "# Hello\n\nSome **bold** text and a [link](https://example.com).",
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
)| Parameter | Meaning |
|---|---|
content |
The Markdown source. It may change often while a reply streams in. |
style |
The body text style, including its color. Not read from LocalTextStyle. |
theme |
A MarkdownTheme: heading styles, link and inline code styles, spacing, colors. |
components |
A MarkdownComponents with the app's code blocks, formulas, tables, images and details. |
softBreakAsLineBreak |
A single newline in a paragraph is a line break (default, as in chat) instead of a space. |
streaming |
true while content is still being generated, see Streaming. |
onLinkClick |
Called with the URL when a link is tapped; null opens it with LocalUriHandler. |
To make the text selectable, wrap it in a BetterSelectionContainer (see BetterText).
MarkdownTheme holds the visual parameters. The defaults are neutral; map your design system onto them. Unspecified colors derive from the text color.
@Composable
fun rememberMarkdownTheme(): MarkdownTheme {
val colors = MaterialTheme.colorScheme
return remember(colors) {
MarkdownTheme(
link = SpanStyle(color = colors.primary, textDecoration = TextDecoration.Underline),
inlineCode = SpanStyle(color = colors.primary, fontFamily = FontFamily.Monospace, fontSize = 0.9.em),
markerColor = colors.primary,
quoteColor = colors.outlineVariant,
dividerColor = colors.outlineVariant,
)
}
}| Field | Default |
|---|---|
headings |
Six TextStyles for h1 to h6, merged onto the body style: bold, 1.5em down to 0.875em, line height 1.25em. Em sizes are relative to the body. |
link |
Underlined. |
inlineCode |
Monospace on a translucent gray background. |
blockSpacing |
12 dp between blocks. |
markerColor |
Color of list markers, numbers and task checkboxes; the text color when unspecified. |
quoteColor |
The bar beside quotes; the text color at 30% when unspecified. |
dividerColor |
Dividers; the text color at 20% when unspecified. |
quotedText |
Style of text in quotation marks ("…", “…”, 「…」, 『…』); null leaves it unstyled. |
Everything that isn't text is registered by the app through MarkdownComponents. When a slot is null there's a plain fallback: code, formulas and tables show their source in monospace, images their alt text as a link, and details blocks their whole content.
Keep the instance stable, as a top-level value or remembered. A new instance on every recomposition rebuilds all the text.
val components = MarkdownComponents(
codeBlock = { block -> CodeCard(block.code, block.language) },
table = { table -> MyTable(table) },
image = { image, link -> MyImage(image, link) },
mathBlock = { formula -> MathBlock(formula) },
details = { block, summary, content -> Collapsible(block.open, summary, content) },
inlineMath = { formula -> LatexInline.cached(formula, context) },
inlineLink = { link -> null },
inlineCodePrefix = { code -> null },
textDecorations = { text -> emptyList() },
)Composable components can match the surrounding text through LocalMarkdownTextStyle, which holds the style of the current block (a heading, a quote, a table cell…). Its font size is always in sp; its color is unspecified when the style given to Markdown has none.
The gallery's MaterialMarkdown.kt registers every component with Material 3.
With the latex module (see LaTeX):
val components = MarkdownComponents(
inlineMath = { formula -> LatexInline.cached(formula, context) },
mathBlock = { formula ->
val style = LocalMarkdownTextStyle.current
LatexBlock(formula, fontSize = style.fontSize, color = style.color)
},
)inlineMath isn't composable: it runs while the text is built, in a MarkdownInlineScope that provides the paragraph's style, density, contentColor and Android context. Return null to show the formula's source. Inline formulas are copied as $formula$.
Display formulas (mathBlock) are separate composables, so they aren't part of the text selection.
Math is written as $…$ and $$…$$. \(…\) and \[…\] are accepted too and converted outside code.
MarkdownBlock.CodeBlock has code, language (the fence's info string, or null) and closed, which is false while the closing fence hasn't streamed in yet. Features that need the complete code, such as previews, shouldn't run until it's true.
With the highlight module (see Highlight), drawn with BetterText so it's selectable with the text around it:
@Composable
fun CodeCard(code: String, language: String?) {
val colors = HighlightTextColorPalette.AtomOneDark
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, color = Color(0xFF282C34)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
language?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = colors.comment) }
BetterText(
text = rememberHighlightedCode(code, language.orEmpty(), colors),
modifier = Modifier.horizontalScroll(rememberScrollState()),
style = MaterialTheme.typography.bodySmall.copy(
color = colors.fallback,
fontFamily = FontFamily.Monospace,
fontFeatureSettings = CODE_FONT_FEATURES,
),
)
}
}
}MarkdownBlock.Table has the header cells, the rows, each column's alignments (from the delimiter row) and the Markdown source (null for an HTML <table>). A cell's style includes what it inherits from the table and its row; its alignment takes precedence over the column's.
Render cell content with MarkdownInlineContent, which draws inline Markdown with the formulas, links and styles of the surrounding Markdown. Only call it from a registered component.
@Composable
fun SimpleTable(table: MarkdownBlock.Table) {
val rows = listOf(table.header) + table.rows
Column(Modifier.horizontalScroll(rememberScrollState())) {
rows.forEachIndexed { r, row ->
Row {
row.forEach { cell ->
MarkdownInlineContent(
cell.content,
modifier = Modifier.width(120.dp).padding(8.dp),
style = TextStyle(fontWeight = if (r == 0) FontWeight.Bold else null).merge(cell.style),
)
}
}
}
}
}List<MarkdownInline>.plainText() extracts a cell's text, for copying or CSV export.
MarkdownInline.Image has url, alt, title, and width and height from an HTML <img>. link is set when the image is wrapped in a link. For example, with Coil:
image = { image, link ->
AsyncImage(
model = image.url,
contentDescription = image.alt,
modifier = Modifier.heightIn(max = 240.dp),
)
}A paragraph with images or display formulas is split at them, and the pieces flow in a row.
<details> blocks get summary and content composables that render their parts, so the component only decides how to lay them out and when to show the content. block.open reflects the open attribute; the tag streams in piece by piece, so follow it if it arrives later:
details = { block, summary, content ->
var expanded by rememberSaveable(block.open) { mutableStateOf(block.open) }
Column {
Row(Modifier.clickable { expanded = !expanded }) { summary() }
AnimatedVisibility(expanded) { content() }
}
}inlineLink renders some links differently, e.g. citations as tags. Return a BetterInline (see BetterText), or null for a normal link. The item copies as the link text.
inlineLink = { link ->
link.destination.removePrefix("cite:").toIntOrNull()?.let { n ->
ComposableInline(InlineAlign.Center) { CitationChip(n) }
}
}inlineCodePrefix draws an item before some inline code, and textDecorations draws items inside plain text. Both copy as nothing. The gallery puts a color swatch before color codes:
inlineCodePrefix = { code -> parseColor(code)?.let { ColorSwatch(it) } },
textDecorations = { text ->
COLOR_REGEX.findAll(text).mapNotNull { match ->
parseColor(match.value)?.let { InlineDecoration(match.range.first, ColorSwatch(it)) }
}.toList()
},textDecorations is called for each run of plain text; InlineDecoration.offset is in that run, and the item is drawn before it.
While a reply is being generated, pass the growing text with streaming = true:
Markdown(content = reply.text, streaming = !reply.done, style = style, components = components)- The first parse is synchronous so the first frame has content. Later ones run on
Dispatchers.Default, and when updates come faster than parsing, only the latest content is parsed. - Only the changed end is parsed again: the document is split into chunks of top-level blocks, and chunks before the change (except the last two) are reused.
- Nodes are immutable data classes, so blocks that didn't change are equal to the previous ones and skip recomposition.
- With
streaming = true, unfinished syntax in the last block is closed or hidden instead of flashing as raw text:**bold,$\frac{,[text](https://exa, a table header without its delimiter row, and so on. Code isn't touched.
Set streaming = false when the reply is complete, so the end is rendered as written.
parseMarkdown is pure and thread-safe; call it on a background thread for long text, and render the result with the Markdown(document = …) overload:
val document = withContext(Dispatchers.Default) { parseMarkdown(text) }
Markdown(document = document, style = style, components = components)rememberMarkdownDocument(content, softBreakAsLineBreak, streaming) is what Markdown(content = …) uses: it follows content and parses it in the background. MarkdownStreamParser is the incremental parser underneath, for use outside composition; it isn't thread-safe, so call parse from one thread at a time.
The document tree (MarkdownDocument, MarkdownBlock, MarkdownInline) is public, so it can also be inspected or rendered by other means.
- CommonMark and GFM: headings, emphasis, strikethrough, links (including reference links and autolinks), images, code spans and fenced code, quotes, ordered, unordered and task lists, tables, dividers.
- Math:
$…$,$$…$$,\(…\),\[…\]. - HTML, nesting with Markdown:
- inline:
<b>,<strong>,<i>,<em>,<u>,<ins>,<s>,<del>,<mark>,<sub>,<sup>,<small>,<big>,<code>,<kbd>,<a>,<img>,<br>,<font color size face>,<span style>and more; - blocks:
<p>,<h1>–<h6>,<ul>/<ol>,<pre>,<table>,<hr>,<blockquote>,<details>/<summary>, and containers such as<div style>and<center>; - inline CSS:
color,background(-color),font-size,font-weight,font-style,font-family,letter-spacing,text-decoration,text-align, and thealignattribute.
- inline:
- Tags with no text to show, such as
<script>,<style>,<iframe>,<video>and form controls, are dropped. - Unknown tags such as
List<String>or<answer>are kept as text instead of being swallowed.