
High-performance Markdown parser and renderer with AST-based incremental parsing, streaming (LLM) rendering, built-in image loading, LaTeX math, customizable themes, pagination, and broad CommonMark/GFM extensions support.
A Blazing-Fast, Cross-Platform Markdown Engine for Compose Multiplatform
One library. One codebase. Pixel-perfect Markdown on Android, iOS, Desktop & Web.
| Feature | Description | |
|---|---|---|
| 🚀 | Incremental Updates | Reuses unchanged content when editing documents or receiving streaming output |
| 🌍 | True Cross-Platform | One codebase renders identically on Android, iOS, Desktop (JVM), Web (Wasm/JS) |
| 📐 | Syntax Support | 413 supported syntax and behavior items, 652/652 CommonMark Spec examples passing, plus GFM & 20+ extensions |
| 🤖 | LLM-Ready Streaming | First-class token-by-token rendering with display-frame coalescing — zero flicker during AI generation |
| 🎨 | Fully Themeable | 30+ configurable properties, built-in GitHub light/dark themes, auto system detection |
| 📊 | LaTeX Math | Inline $...$ and block $$...$$ formulas via integrated LaTeX rendering engine |
| 🔍 | Built-in Linting | 13+ diagnostic rules including WCAG accessibility checks — catch issues at parse time |
| 🖼️ | Image Loading | Coil3 + Ktor3 out-of-the-box, with size specification and custom renderer support |
| 🖱️ | Cross-Block Selection | Select text across blocks with the system copy/translate menu |
Long-press and drag to select text. After releasing the gesture or adjusting a selection handle, tap a blank area inside the Markdown content once to clear the selection.
Real-time token-by-token output with incremental parsing — no flicker, no re-render.
Built-in linting with WCAG accessibility checks — heading jumps, broken footnotes, empty links, and more.
Full CommonMark HTML parsing/export, safe Compose rendering for common inline and container HTML tags, GFM tables, admonitions, math, code highlighting, and 20+ extensions.
Add to your gradle/libs.versions.toml:
[versions]
markdown = "1.5.0"
[libraries]
markdown-parser = { module = "io.github.huarangmeng:markdown-parser", version.ref = "markdown" }
markdown-runtime = { module = "io.github.huarangmeng:markdown-runtime", version.ref = "markdown" }
markdown-renderer = { module = "io.github.huarangmeng:markdown-renderer", version.ref = "markdown" }Then in your module's build.gradle.kts:
dependencies {
implementation(libs.markdown.parser)
implementation(libs.markdown.runtime)
implementation(libs.markdown.renderer)
}💡
markdown-rendererbundles Coil3 + Ktor3 for image loading, anddiagram-renderfor Mermaid / PlantUML / DOT blocks, as transitive dependencies.
import com.hrm.markdown.renderer.Markdown
@Composable
fun MyScreen() {
Markdown(
markdown = """
# Hello World
This is a paragraph with **bold** and *italic* text.
- Item 1
- Item 2
```kotlin
fun hello() = println("Hello")
```
""".trimIndent(),
modifier = Modifier.fillMaxSize(),
)
}That's it — 3 lines to render beautiful Markdown across all platforms.
Pass the native LatexConfig directly. Inline and block formulas share its fonts, theme, callbacks, and measurements.
import com.hrm.latex.renderer.model.LatexConfig
import com.hrm.latex.renderer.model.LatexTheme
Markdown(
markdown = text,
latexConfig = LatexConfig(
fontSize = 24.sp,
theme = LatexTheme.material3(),
accessibilityEnabled = true,
onHyperlinkClick = { url -> /* host navigation */ },
),
)The default is LatexConfig() (20.sp, system light/dark, transparent background). Markdown colors do not override it.
We recommend keeping the bundled KaTeX fonts: system fonts may have missing glyphs or layout differences.
If you need to omit the font resources, exclude latex-fonts-katex. The default configuration safely falls back to system fonts without crashing when fonts are missing:
implementation(libs.markdown.renderer) {
exclude(group = "io.github.huarangmeng", module = "latex-fonts-katex")
}Use directivePlugins to add custom syntax such as video embeds and render it with native Compose
components. Plugins can also handle the standard {% tag ... %} directive syntax and HTML export.
Basic usage:
Markdown(
markdown = """
Custom syntax:
!VIDEO[Demo](https://cdn.example.com/a.mp4){poster=https://cdn.example.com/a.jpg}
""".trimIndent(),
directivePlugins = listOf(VideoDirectivePlugin),
)Plugin skeleton:
object VideoDirectivePlugin : MarkdownDirectivePlugin {
override val id: String = "video"
override val inputTransformers = listOf(VideoSyntaxTransformer())
override val blockDirectiveRenderers = mapOf(
"video" to { scope ->
VideoPlayer(
url = scope.directive.args.getValue("url"),
poster = scope.directive.args["poster"],
title = scope.directive.args["title"],
)
}
)
}
class VideoSyntaxTransformer : MarkdownInputTransformer {
override val id: String = "video-syntax"
override fun transform(input: String): MarkdownTransformResult {
val normalized = input.replace(
Regex("""!VIDEO\[(.*?)\]\((.*?)\)\{poster=(.*?)\}""")
) { match ->
val title = match.groupValues[1]
val url = match.groupValues[2]
val poster = match.groupValues[3]
"""{% video title="$title" url="$url" poster="$poster" %}"""
}
return MarkdownTransformResult(markdown = normalized)
}
}Input transformers stream by default with
MarkdownTransformerStreamingSupport.RestartOnRewrite. Each transformed revision is checked
against the previous transformed prefix; if existing output changed, the parser session restarts
atomically from the complete transformed snapshot. A transformer should declare AppendSafe only
when a longer append-only input always preserves the complete previous transformed prefix, and may
declare Unsupported to explicitly require isolated full parses. The active mapping back to
original source offsets is available through LocalMarkdownSourceMap.
Treat plugins and transformers as configuration: reuse instances while their behavior stays the same, and provide new instances when it changes. Replacing only rendering callbacks preserves the parser session; replacing input transformers automatically refreshes parsing and source mappings.
DirectiveBlockRenderScope and DirectiveInlineRenderScope are snapshot-based.
Use scope.directive as the single structured entry point for directive data.
For HTML export, HtmlDirectiveFallback and HtmlInlineDirectiveFallback also receive
snapshot objects instead of parser AST nodes.
HTML export uses the same directive pipeline:
val html = MarkdownHtml.render(
markdown = markdown,
directivePlugins = listOf(VideoDirectivePlugin),
)Purpose-built for AI/LLM scenarios. Enable isStreaming for flicker-free incremental rendering:
var text by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
llmTokenFlow.collect { token ->
text += token
}
isStreaming = false
}
Markdown(
markdown = text,
isStreaming = isStreaming,
parserConfig = MarkdownParserConfig.LlmStreaming, // 16-char parser coalescing + frame-coalesced UI commits
)LlmStreaming uses a 16-character parser coalescing threshold; newlines commit early, and stopping
or cancelling flushes the remaining tail. Code appears as it arrives, including inside unclosed fences.
Use Markdown(document = parser.document) to render the current parser publication. Build custom
syntax through MarkdownFlavour and perform AST transformations in its processors before publication.
Published ASTs are read-only; edit text through the parser's insert, delete or replace methods.
For local transformations, implement BlockPostProcessor.processBlock(block): return the same block,
a replacement, or null to remove it. The engine handles incremental updates automatically; use
PostProcessor for transformations that depend on the whole document. Retained AST references are
current views, not immutable historical snapshots; pass the latest parser document after each update.
When the document changes during text selection, selection endpoints and active drag handles are clamped to valid UTF-16 character boundaries. Removing a selected block clears the selection and drag state, so copying cannot use stale endpoints.
MarkdownInteractions groups typed link, citation ([@key]) and image callbacks. When no link callback is supplied, links use Compose LocalUriHandler.
import androidx.compose.ui.unit.dp
import com.hrm.markdown.renderer.Markdown
import com.hrm.markdown.renderer.MarkdownBlockSlot
import com.hrm.markdown.renderer.MarkdownComponents
import com.hrm.markdown.parser.MarkdownParserConfig
import com.hrm.markdown.renderer.MarkdownInteractions
import com.hrm.markdown.renderer.MarkdownSoftBreakPolicy
Markdown(
markdown = text,
interactions = MarkdownInteractions(
onLinkClick = { url -> /* navigate */ },
onCitationClick = { event -> /* event.key, event.anchorBoundsInWindow */ },
onImageClick = { event ->
// Read event.gallery only when opening a document-order image viewer.
val gallery = event.gallery
// gallery.images[gallery.initialIndex] is the clicked occurrence.
},
),
softBreakPolicy = MarkdownSoftBreakPolicy.LineBreak,
components = MarkdownComponents(
codeBlockHeader = MarkdownBlockSlot(48.dp) { code ->
TextButton(onClick = { runCode(code.code, code.language) }) { Text("Run") }
},
tableToolbar = MarkdownBlockSlot(48.dp) { table ->
Text("${table.rowCount} rows / ${table.columnCount} columns")
},
),
)The host implements runCode. Images use Coil by default; customize them with components.image. event.gallery provides the document image gallery.
Keep slot content within its declared height; toolbar text is excluded from Markdown copy. MarkdownTableInfo.rowCount includes the header row.
Set enableScroll = false when a parent container handles vertical scrolling.
Soft breaks default to Space. Use LineBreak to preserve line breaks in display and copy. HTML export also accepts softBreakPolicy.
All settings are optional. Markdown(markdown = text) follows system colors. Native Markdown settings have three independent groups; code, LaTeX, and diagrams retain their native configuration types.
import androidx.compose.foundation.layout.PaddingValues
import com.hrm.codehigh.renderer.CodeDefaults
import com.hrm.codehigh.renderer.CodeTypography
import com.hrm.codehigh.renderer.CodeLayout
Markdown(
markdown = text,
colors = MarkdownDefaults.colors(link = Color(0xFF0969DA)),
typography = MarkdownTypography(
h1 = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold),
text = TextStyle(fontSize = 16.sp, lineHeight = 28.sp),
),
layout = MarkdownLayout(blockSpacing = 12.dp),
codeColors = CodeDefaults.oneDarkProColors(),
codeTypography = CodeTypography(
text = TextStyle(fontSize = 18.sp, lineHeight = 28.sp),
inlineCode = TextStyle(fontSize = 15.sp),
),
codeLayout = CodeLayout(contentPadding = PaddingValues(16.dp)),
interactions = MarkdownInteractions(
onLinkClick = { url -> /* host navigation */ },
onCodeTokenClick = { token -> /* codehigh CodeToken */ },
),
)Choose palettes with MarkdownDefaults.lightColors(), darkColors(), or material3Colors(). ProvideMarkdownDefaults sets app defaults; instance arguments take precedence.
Code settings reuse codehigh's CodeColors, CodeTypography, and CodeLayout, so the same configuration works with both Markdown and CodeBlock.
| Purpose | Parameter |
|---|---|
| Source parsing: flavour, emoji, lint, append coalescing | parserConfig = MarkdownParserConfig(...) |
| Soft breaks and heading numbering |
softBreakPolicy, enableHeadingNumbering
|
| Internal scroll and selection |
enableScroll, enableSelection
|
| Streaming lifecycle | isStreaming |
| Link, citation, image, and code-token handlers | interactions = MarkdownInteractions(...) |
| Header/footer, image renderer, code/table chrome, admonition icons | components = MarkdownComponents(...) |
| Code appearance (native codehigh types) |
codeColors, codeTypography, codeLayout
|
| LaTeX and diagram settings |
latexConfig, diagramTheme
|
| Directive extensions | directivePlugins |
onCodeTokenClick receives codehigh's CodeToken; its range is relative to the containing code block.
val state = rememberMarkdownState()
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { state.scrollToBlock(10) } }) { Text("Jump") }
Markdown(markdown = text, state = state, components = MarkdownComponents(
header = { Text("Introduction") },
footer = { Text("End") },
))Use rememberMarkdownState() to control scrolling and clear selection. Block indexes exclude header/footer.
| Feature | Details |
|---|---|
| Headings | ATX (# ~ ######), Setext (===/---), custom IDs ({#id}), auto-generated anchors |
| Paragraphs | Multi-line merging, blank line separation, lazy continuation |
| Code Blocks | Fenced (```/~~~) with language highlight (20+ languages), indented, line numbers, line highlighting |
| Block Quotes | Nested, lazy continuation, inner block elements |
| Lists | Unordered/ordered/task lists, nested, tight/loose distinction; task checkboxes have an 8.dp gap before content |
| Tables (GFM) | Column alignment, inline formatting in cells, escaped pipes |
| Thematic Breaks |
---, ***, ___
|
| HTML Blocks | All 7 CommonMark types; safe Compose fragments for common containers, headings, quotes, code blocks, lists, and text alignment |
| Link Reference Definitions | Full support with title variants |
| Feature | Details |
|---|---|
| Emphasis | Bold, italic, bold-italic, nested, CJK-aware delimiter rules |
| Strikethrough |
~~text~~, ~text~
|
| Inline Code | Single/multi backtick, space stripping |
| Links | Inline, reference (full/collapsed/shortcut), autolinks, GFM bare URLs, attribute blocks |
| Images | Inline, reference, =WxH size specification, attribute blocks, auto Figure conversion |
| Inline HTML | CommonMark tags/comments/CDATA/processing instructions; safe Compose semantics for common formatting, links, breaks, and images |
| Escapes & Entities | 32 escapable characters, named/numeric HTML entities |
| Line Breaks | Hard (spaces/backslash), soft |
| Feature | Syntax |
|---|---|
| Math (LaTeX) |
$...$ inline, $$...$$ block, \tag{}, \ref{}
|
| Footnotes |
[^label] references, multi-line definitions, block content |
| Admonitions |
> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
|
| Highlight | ==text== |
| Super/Subscript |
^text^, ~text~, <sup>, <sub>
|
| Insert Text | ++text++ |
| Emoji |
:smile: shortcodes (200+), ASCII emoticons (40+), custom mappings |
| Definition Lists | Term + : definition format |
| Front Matter | YAML (---) and TOML (+++) |
| TOC |
[TOC] with depth, exclude, ordering options |
| Custom Containers |
:::type with nesting, CSS classes, IDs |
| Diagram Blocks | Mermaid, PlantUML, Graphviz, and more |
| Multi-Column Layout |
:::columns with percentage/pixel widths |
| Tab Blocks |
=== "Tab Title" MkDocs Material style |
| Directives |
{% tag args %}...{% endtag %} with positional/keyword args |
| Spoiler Text |
>!hidden text!< Discord/Reddit style |
| Wiki Links |
[[page]], [[page|display text]]
|
| Ruby Text |
{漢字|かんじ} pronunciation annotations |
| Bibliography |
[@key] citations with [^bibliography] definitions |
| Block Attributes |
{.class #id key=value} kramdown/Pandoc style |
| Page Breaks |
***pagebreak*** for print/PDF export |
| Styled Text |
[text]{.red style="color:red"} inline CSS |
Enable once, catch problems everywhere:
val parser = MarkdownParser(MarkdownParserConfig(enableLinting = true))
val document = parser.parse(markdown)
document.diagnostics.forEach { diagnostic ->
println("Line ${diagnostic.line}: [${diagnostic.severity}] ${diagnostic.message}")
}13+ diagnostic rules:
With linting enabled, parser.diagnostics describes the currently published parser.document.
beginStream() clears both; buffered chunks keep the last published diagnostics until the next parse is committed.
| Rule | Severity | Description |
|---|---|---|
| Heading level skip | h1 → h3 without h2 | |
| Duplicate heading ID | Multiple headings generate the same anchor | |
| Invalid footnote ref | ❌ ERROR | Reference to undefined footnote |
| Unused footnote | Footnote defined but never referenced | |
| Empty link target |
[text]() with no URL |
|
| Missing alt text | Images without description | |
| Empty link text | Links invisible to screen readers | |
| Non-descriptive link | "click here", "read more" links | |
| Missing code language | ℹ️ INFO | Fenced code without language tag |
| Table missing header | Screen readers need <th>
|
|
| Long alt text | Alt text > 125 characters |
These rules check selected accessibility-related source issues; they do not certify full WCAG compliance.
The Compose renderer provides basic reading semantics by default, independently of enableLinting:
These semantics are supplied by the shared Compose rendering path. Platform screen readers, including TalkBack and VoiceOver, still need validation on target devices. The preview includes a mixed-content example under “渲染与质量 → 无障碍阅读”.
| Spec | Status |
|---|---|
| CommonMark 0.31.2 | 652/652 (100%) ✅ |
| GFM 0.29 | Tables, task lists, strikethrough, autolinks ✅ |
| Markdown Extra | Footnotes, definition lists, abbreviations, fenced code ✅ |
| Area | Verified behavior |
|---|---|
| Incremental and streaming parsing | Matches independent full parsing across four flavours, including continuous edits and streamed prefixes |
| Asynchronous lifecycle | Regression coverage for full-text replacement, cancellation, configuration changes, and page disposal |
| Extreme inputs | Isolated-process checks pass for 512 nested quotes, million-character lines, dense delimiters, and large unclosed blocks |
| Regression checks | JVM tests and parser performance budgets pass; parser and renderer tests compile for JS and Wasm |
See the CI results for the current revision.
Configure which syntax features to enable:
// Strict CommonMark — no extensions
val doc = MarkdownParser(CommonMarkFlavour).parse(input)
// GFM — CommonMark + tables, strikethrough, autolinks
val doc = MarkdownParser(GFMFlavour).parse(input)
// Extended (default) — everything enabled
val doc = MarkdownParser().parse(input)
// One-shot HTML rendering
val html = HtmlRenderer.renderMarkdown(input, flavour = CommonMarkFlavour)MarkdownParserConfig lives in com.hrm.markdown.parser. Share one configuration across the parser,
Compose and HTML export:
import com.hrm.markdown.parser.MarkdownParserConfig
val config = MarkdownParserConfig(flavour = GFMFlavour, enableLinting = true)
val parser = MarkdownParser(config)
val document = parser.parse(input)
Markdown(markdown = input, parserConfig = config)
val html = MarkdownHtml.render(input, parserConfig = config)Configuration copies and protects the supplied emoji map; later changes to that map do not change an
existing parser session. Built-in flavours are immutable configurations. Use copy to replace settings
or with to append syntax starters or processors while retaining the other syntax settings.
Custom block transformations receive and return a block; the library handles incremental updates:
val flavour = CommonMarkFlavour.with(BlockPostProcessor { block ->
if (block is Heading) block.level = minOf(block.level, 3)
block
})
val doc = MarkdownParser(flavour).parse(input)BlockPostProcessor transforms independent blocks, returning the input, a replacement or null.
Use the separate PostProcessor { document -> ... } for document-wide transformations. Both run before
publication; callers manage no revisions, changed ranges or caches. A configuration can be shared by
independent parsers; stateful extension instances remain responsible for their own concurrency.
Markdown() renders a safe, cross-platform subset of HTML without a WebView:
Text with <strong>bold</strong>, <em>italic</em>,
<span style="color:red">styled text</span>, and <a href="https://example.com">a link</a>.<br>
The next line can also contain an <img src="https://example.com/icon.png" alt="icon">.Supported semantic tags are strong/b, em/i, del/s/strike, mark,
sup, sub, ins, u, code, kbd, span, a, br, and img. Inline HTML
comments are hidden. Formatting tags and br accept no attributes; span accepts only
fully supported style/class values; a accepts only href; and img accepts only
src, alt, title, width, and height. Supported span CSS is limited to colors,
background colors, font weight/style, and underline/line-through. Any unknown attribute,
CSS declaration, unsafe URL, mismatched tag, or unclosed tag causes that HTML source to
remain visible instead of being partially rendered or silently losing content.
Safe block fragments are supported for p, div, center, article, section, main,
header, footer, h1-h6, blockquote, hr, safe pre/code code blocks, and safe
ul/ol/li list structures. Containers can contain the safe inline tags above and use
align or the limited style="text-align:..." form with left, start, center,
right, or end. ol accepts an integer start attribute; ul and li accept no
attributes. pre can contain text directly or a single code child; code accepts only
class="language-..." or class="lang-..." to declare a language. left/right remain
physical directions, while start/end follow the current layout direction. Compose
routing is explicit by CommonMark HTML block type: comments (type 2) may be hidden, safe
type 6/7 fragments may be mapped, and safe type 1 pre code fragments may be mapped;
other type 1 fragments and types 3/4/5 stay raw. Malformed fragments, unknown attributes,
or unsupported structures such as tables, forms, scripts, and arbitrary CSS fall back
atomically to visible raw HTML.
A block tag must start on its own line according to CommonMark rules. In particular,
text <p>block</p> text is invalid block markup and remains inline source; put the p element
on its own line. HtmlRenderer/MarkdownHtml continue to pass all raw HTML through when exporting HTML.
Built-in Coil3 handles images automatically. Need custom rendering? Easy:
Markdown(
markdown = markdownText,
components = MarkdownComponents(image = { data, modifier ->
// data.url, data.altText, data.width, data.height available
AsyncImage(
model = data.url,
contentDescription = data.altText,
modifier = modifier,
)
}),
)Supports size specification in Markdown:

# Android
./gradlew :composeApp:assembleDebug
# Desktop (JVM)
./gradlew :composeApp:run
# Web (Wasm)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Web (JS)
./gradlew :composeApp:jsBrowserDevelopmentRun
# iOS — open iosApp/ in Xcode| # | Category | Supported / Listed |
|---|---|---|
| 1 | Headings | 17/17 (100%) |
| 2 | Paragraphs | 5/5 (100%) |
| 3 | Code Blocks | 17/17 (100%) |
| 4 | Block Quotes | 8/8 (100%) |
| 5 | Lists | 20/20 (100%) |
| 6 | Thematic Breaks | 6/6 (100%) |
| 7 | Tables (GFM) | 11/11 (100%) |
| 8 | HTML Blocks | 12/12 (100%) |
| 9 | Link References | 12/12 (100%) |
| 10 | Block Extensions | 85/85 (100%) |
| 11 | Emphasis | 13/13 (100%) |
| 12 | Strikethrough | 4/4 (100%) |
| 13 | Inline Code | 8/8 (100%) |
| 14 | Links | 27/27 (100%) |
| 15 | Images | 17/17 (100%) |
| 16 | Inline HTML | 10/10 (100%) |
| 17 | Escapes & Entities | 10/10 (100%) |
| 18 | Line Breaks | 5/5 (100%) |
| 19 | Inline Extensions | 50/50 (100%) |
| 20 | Streaming Engine | 27/27 (100%) |
| 21 | Character & Encoding | 10/10 (100%) |
| 22 | HTML Generator | 12/12 (100%) |
| 23 | Linting / WCAG | 19/19 (100%) |
| 24 | Directives | 8/8 (100%) |
| Total | 413/413 (100%) |
📖 Full details: PARSER_COVERAGE_ANALYSIS.md
markdown-renderer integrates the following external libraries for math, code highlighting, and diagrams:
| Capability | Maven modules used by this project | Repository |
|---|---|---|
| LaTeX math |
io.github.huarangmeng:latex-base, io.github.huarangmeng:latex-parser, io.github.huarangmeng:latex-renderer, io.github.huarangmeng:latex-fonts-katex
|
huarangmeng/latex |
| Code highlighting |
io.github.huarangmeng:codehighlight-parser, io.github.huarangmeng:codehighlight-render
|
huarangmeng/codehigh |
| Diagram blocks |
io.github.huarangmeng:diagram-core, io.github.huarangmeng:diagram-layout, io.github.huarangmeng:diagram-parser, io.github.huarangmeng:diagram-render
|
huarangmeng/diagram |
If you only need a subset of the rendering stack, you can depend on the corresponding upstream library directly.
MIT License · Copyright (c) 2026 huarangmeng
This project is licensed under the MIT License — see the LICENSE file for details.
A Blazing-Fast, Cross-Platform Markdown Engine for Compose Multiplatform
One library. One codebase. Pixel-perfect Markdown on Android, iOS, Desktop & Web.
| Feature | Description | |
|---|---|---|
| 🚀 | Incremental Updates | Reuses unchanged content when editing documents or receiving streaming output |
| 🌍 | True Cross-Platform | One codebase renders identically on Android, iOS, Desktop (JVM), Web (Wasm/JS) |
| 📐 | Syntax Support | 413 supported syntax and behavior items, 652/652 CommonMark Spec examples passing, plus GFM & 20+ extensions |
| 🤖 | LLM-Ready Streaming | First-class token-by-token rendering with display-frame coalescing — zero flicker during AI generation |
| 🎨 | Fully Themeable | 30+ configurable properties, built-in GitHub light/dark themes, auto system detection |
| 📊 | LaTeX Math | Inline $...$ and block $$...$$ formulas via integrated LaTeX rendering engine |
| 🔍 | Built-in Linting | 13+ diagnostic rules including WCAG accessibility checks — catch issues at parse time |
| 🖼️ | Image Loading | Coil3 + Ktor3 out-of-the-box, with size specification and custom renderer support |
| 🖱️ | Cross-Block Selection | Select text across blocks with the system copy/translate menu |
Long-press and drag to select text. After releasing the gesture or adjusting a selection handle, tap a blank area inside the Markdown content once to clear the selection.
Real-time token-by-token output with incremental parsing — no flicker, no re-render.
Built-in linting with WCAG accessibility checks — heading jumps, broken footnotes, empty links, and more.
Full CommonMark HTML parsing/export, safe Compose rendering for common inline and container HTML tags, GFM tables, admonitions, math, code highlighting, and 20+ extensions.
Add to your gradle/libs.versions.toml:
[versions]
markdown = "1.5.0"
[libraries]
markdown-parser = { module = "io.github.huarangmeng:markdown-parser", version.ref = "markdown" }
markdown-runtime = { module = "io.github.huarangmeng:markdown-runtime", version.ref = "markdown" }
markdown-renderer = { module = "io.github.huarangmeng:markdown-renderer", version.ref = "markdown" }Then in your module's build.gradle.kts:
dependencies {
implementation(libs.markdown.parser)
implementation(libs.markdown.runtime)
implementation(libs.markdown.renderer)
}💡
markdown-rendererbundles Coil3 + Ktor3 for image loading, anddiagram-renderfor Mermaid / PlantUML / DOT blocks, as transitive dependencies.
import com.hrm.markdown.renderer.Markdown
@Composable
fun MyScreen() {
Markdown(
markdown = """
# Hello World
This is a paragraph with **bold** and *italic* text.
- Item 1
- Item 2
```kotlin
fun hello() = println("Hello")
```
""".trimIndent(),
modifier = Modifier.fillMaxSize(),
)
}That's it — 3 lines to render beautiful Markdown across all platforms.
Pass the native LatexConfig directly. Inline and block formulas share its fonts, theme, callbacks, and measurements.
import com.hrm.latex.renderer.model.LatexConfig
import com.hrm.latex.renderer.model.LatexTheme
Markdown(
markdown = text,
latexConfig = LatexConfig(
fontSize = 24.sp,
theme = LatexTheme.material3(),
accessibilityEnabled = true,
onHyperlinkClick = { url -> /* host navigation */ },
),
)The default is LatexConfig() (20.sp, system light/dark, transparent background). Markdown colors do not override it.
We recommend keeping the bundled KaTeX fonts: system fonts may have missing glyphs or layout differences.
If you need to omit the font resources, exclude latex-fonts-katex. The default configuration safely falls back to system fonts without crashing when fonts are missing:
implementation(libs.markdown.renderer) {
exclude(group = "io.github.huarangmeng", module = "latex-fonts-katex")
}Use directivePlugins to add custom syntax such as video embeds and render it with native Compose
components. Plugins can also handle the standard {% tag ... %} directive syntax and HTML export.
Basic usage:
Markdown(
markdown = """
Custom syntax:
!VIDEO[Demo](https://cdn.example.com/a.mp4){poster=https://cdn.example.com/a.jpg}
""".trimIndent(),
directivePlugins = listOf(VideoDirectivePlugin),
)Plugin skeleton:
object VideoDirectivePlugin : MarkdownDirectivePlugin {
override val id: String = "video"
override val inputTransformers = listOf(VideoSyntaxTransformer())
override val blockDirectiveRenderers = mapOf(
"video" to { scope ->
VideoPlayer(
url = scope.directive.args.getValue("url"),
poster = scope.directive.args["poster"],
title = scope.directive.args["title"],
)
}
)
}
class VideoSyntaxTransformer : MarkdownInputTransformer {
override val id: String = "video-syntax"
override fun transform(input: String): MarkdownTransformResult {
val normalized = input.replace(
Regex("""!VIDEO\[(.*?)\]\((.*?)\)\{poster=(.*?)\}""")
) { match ->
val title = match.groupValues[1]
val url = match.groupValues[2]
val poster = match.groupValues[3]
"""{% video title="$title" url="$url" poster="$poster" %}"""
}
return MarkdownTransformResult(markdown = normalized)
}
}Input transformers stream by default with
MarkdownTransformerStreamingSupport.RestartOnRewrite. Each transformed revision is checked
against the previous transformed prefix; if existing output changed, the parser session restarts
atomically from the complete transformed snapshot. A transformer should declare AppendSafe only
when a longer append-only input always preserves the complete previous transformed prefix, and may
declare Unsupported to explicitly require isolated full parses. The active mapping back to
original source offsets is available through LocalMarkdownSourceMap.
Treat plugins and transformers as configuration: reuse instances while their behavior stays the same, and provide new instances when it changes. Replacing only rendering callbacks preserves the parser session; replacing input transformers automatically refreshes parsing and source mappings.
DirectiveBlockRenderScope and DirectiveInlineRenderScope are snapshot-based.
Use scope.directive as the single structured entry point for directive data.
For HTML export, HtmlDirectiveFallback and HtmlInlineDirectiveFallback also receive
snapshot objects instead of parser AST nodes.
HTML export uses the same directive pipeline:
val html = MarkdownHtml.render(
markdown = markdown,
directivePlugins = listOf(VideoDirectivePlugin),
)Purpose-built for AI/LLM scenarios. Enable isStreaming for flicker-free incremental rendering:
var text by remember { mutableStateOf("") }
var isStreaming by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
llmTokenFlow.collect { token ->
text += token
}
isStreaming = false
}
Markdown(
markdown = text,
isStreaming = isStreaming,
parserConfig = MarkdownParserConfig.LlmStreaming, // 16-char parser coalescing + frame-coalesced UI commits
)LlmStreaming uses a 16-character parser coalescing threshold; newlines commit early, and stopping
or cancelling flushes the remaining tail. Code appears as it arrives, including inside unclosed fences.
Use Markdown(document = parser.document) to render the current parser publication. Build custom
syntax through MarkdownFlavour and perform AST transformations in its processors before publication.
Published ASTs are read-only; edit text through the parser's insert, delete or replace methods.
For local transformations, implement BlockPostProcessor.processBlock(block): return the same block,
a replacement, or null to remove it. The engine handles incremental updates automatically; use
PostProcessor for transformations that depend on the whole document. Retained AST references are
current views, not immutable historical snapshots; pass the latest parser document after each update.
When the document changes during text selection, selection endpoints and active drag handles are clamped to valid UTF-16 character boundaries. Removing a selected block clears the selection and drag state, so copying cannot use stale endpoints.
MarkdownInteractions groups typed link, citation ([@key]) and image callbacks. When no link callback is supplied, links use Compose LocalUriHandler.
import androidx.compose.ui.unit.dp
import com.hrm.markdown.renderer.Markdown
import com.hrm.markdown.renderer.MarkdownBlockSlot
import com.hrm.markdown.renderer.MarkdownComponents
import com.hrm.markdown.parser.MarkdownParserConfig
import com.hrm.markdown.renderer.MarkdownInteractions
import com.hrm.markdown.renderer.MarkdownSoftBreakPolicy
Markdown(
markdown = text,
interactions = MarkdownInteractions(
onLinkClick = { url -> /* navigate */ },
onCitationClick = { event -> /* event.key, event.anchorBoundsInWindow */ },
onImageClick = { event ->
// Read event.gallery only when opening a document-order image viewer.
val gallery = event.gallery
// gallery.images[gallery.initialIndex] is the clicked occurrence.
},
),
softBreakPolicy = MarkdownSoftBreakPolicy.LineBreak,
components = MarkdownComponents(
codeBlockHeader = MarkdownBlockSlot(48.dp) { code ->
TextButton(onClick = { runCode(code.code, code.language) }) { Text("Run") }
},
tableToolbar = MarkdownBlockSlot(48.dp) { table ->
Text("${table.rowCount} rows / ${table.columnCount} columns")
},
),
)The host implements runCode. Images use Coil by default; customize them with components.image. event.gallery provides the document image gallery.
Keep slot content within its declared height; toolbar text is excluded from Markdown copy. MarkdownTableInfo.rowCount includes the header row.
Set enableScroll = false when a parent container handles vertical scrolling.
Soft breaks default to Space. Use LineBreak to preserve line breaks in display and copy. HTML export also accepts softBreakPolicy.
All settings are optional. Markdown(markdown = text) follows system colors. Native Markdown settings have three independent groups; code, LaTeX, and diagrams retain their native configuration types.
import androidx.compose.foundation.layout.PaddingValues
import com.hrm.codehigh.renderer.CodeDefaults
import com.hrm.codehigh.renderer.CodeTypography
import com.hrm.codehigh.renderer.CodeLayout
Markdown(
markdown = text,
colors = MarkdownDefaults.colors(link = Color(0xFF0969DA)),
typography = MarkdownTypography(
h1 = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.Bold),
text = TextStyle(fontSize = 16.sp, lineHeight = 28.sp),
),
layout = MarkdownLayout(blockSpacing = 12.dp),
codeColors = CodeDefaults.oneDarkProColors(),
codeTypography = CodeTypography(
text = TextStyle(fontSize = 18.sp, lineHeight = 28.sp),
inlineCode = TextStyle(fontSize = 15.sp),
),
codeLayout = CodeLayout(contentPadding = PaddingValues(16.dp)),
interactions = MarkdownInteractions(
onLinkClick = { url -> /* host navigation */ },
onCodeTokenClick = { token -> /* codehigh CodeToken */ },
),
)Choose palettes with MarkdownDefaults.lightColors(), darkColors(), or material3Colors(). ProvideMarkdownDefaults sets app defaults; instance arguments take precedence.
Code settings reuse codehigh's CodeColors, CodeTypography, and CodeLayout, so the same configuration works with both Markdown and CodeBlock.
| Purpose | Parameter |
|---|---|
| Source parsing: flavour, emoji, lint, append coalescing | parserConfig = MarkdownParserConfig(...) |
| Soft breaks and heading numbering |
softBreakPolicy, enableHeadingNumbering
|
| Internal scroll and selection |
enableScroll, enableSelection
|
| Streaming lifecycle | isStreaming |
| Link, citation, image, and code-token handlers | interactions = MarkdownInteractions(...) |
| Header/footer, image renderer, code/table chrome, admonition icons | components = MarkdownComponents(...) |
| Code appearance (native codehigh types) |
codeColors, codeTypography, codeLayout
|
| LaTeX and diagram settings |
latexConfig, diagramTheme
|
| Directive extensions | directivePlugins |
onCodeTokenClick receives codehigh's CodeToken; its range is relative to the containing code block.
val state = rememberMarkdownState()
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { state.scrollToBlock(10) } }) { Text("Jump") }
Markdown(markdown = text, state = state, components = MarkdownComponents(
header = { Text("Introduction") },
footer = { Text("End") },
))Use rememberMarkdownState() to control scrolling and clear selection. Block indexes exclude header/footer.
| Feature | Details |
|---|---|
| Headings | ATX (# ~ ######), Setext (===/---), custom IDs ({#id}), auto-generated anchors |
| Paragraphs | Multi-line merging, blank line separation, lazy continuation |
| Code Blocks | Fenced (```/~~~) with language highlight (20+ languages), indented, line numbers, line highlighting |
| Block Quotes | Nested, lazy continuation, inner block elements |
| Lists | Unordered/ordered/task lists, nested, tight/loose distinction; task checkboxes have an 8.dp gap before content |
| Tables (GFM) | Column alignment, inline formatting in cells, escaped pipes |
| Thematic Breaks |
---, ***, ___
|
| HTML Blocks | All 7 CommonMark types; safe Compose fragments for common containers, headings, quotes, code blocks, lists, and text alignment |
| Link Reference Definitions | Full support with title variants |
| Feature | Details |
|---|---|
| Emphasis | Bold, italic, bold-italic, nested, CJK-aware delimiter rules |
| Strikethrough |
~~text~~, ~text~
|
| Inline Code | Single/multi backtick, space stripping |
| Links | Inline, reference (full/collapsed/shortcut), autolinks, GFM bare URLs, attribute blocks |
| Images | Inline, reference, =WxH size specification, attribute blocks, auto Figure conversion |
| Inline HTML | CommonMark tags/comments/CDATA/processing instructions; safe Compose semantics for common formatting, links, breaks, and images |
| Escapes & Entities | 32 escapable characters, named/numeric HTML entities |
| Line Breaks | Hard (spaces/backslash), soft |
| Feature | Syntax |
|---|---|
| Math (LaTeX) |
$...$ inline, $$...$$ block, \tag{}, \ref{}
|
| Footnotes |
[^label] references, multi-line definitions, block content |
| Admonitions |
> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
|
| Highlight | ==text== |
| Super/Subscript |
^text^, ~text~, <sup>, <sub>
|
| Insert Text | ++text++ |
| Emoji |
:smile: shortcodes (200+), ASCII emoticons (40+), custom mappings |
| Definition Lists | Term + : definition format |
| Front Matter | YAML (---) and TOML (+++) |
| TOC |
[TOC] with depth, exclude, ordering options |
| Custom Containers |
:::type with nesting, CSS classes, IDs |
| Diagram Blocks | Mermaid, PlantUML, Graphviz, and more |
| Multi-Column Layout |
:::columns with percentage/pixel widths |
| Tab Blocks |
=== "Tab Title" MkDocs Material style |
| Directives |
{% tag args %}...{% endtag %} with positional/keyword args |
| Spoiler Text |
>!hidden text!< Discord/Reddit style |
| Wiki Links |
[[page]], [[page|display text]]
|
| Ruby Text |
{漢字|かんじ} pronunciation annotations |
| Bibliography |
[@key] citations with [^bibliography] definitions |
| Block Attributes |
{.class #id key=value} kramdown/Pandoc style |
| Page Breaks |
***pagebreak*** for print/PDF export |
| Styled Text |
[text]{.red style="color:red"} inline CSS |
Enable once, catch problems everywhere:
val parser = MarkdownParser(MarkdownParserConfig(enableLinting = true))
val document = parser.parse(markdown)
document.diagnostics.forEach { diagnostic ->
println("Line ${diagnostic.line}: [${diagnostic.severity}] ${diagnostic.message}")
}13+ diagnostic rules:
With linting enabled, parser.diagnostics describes the currently published parser.document.
beginStream() clears both; buffered chunks keep the last published diagnostics until the next parse is committed.
| Rule | Severity | Description |
|---|---|---|
| Heading level skip | h1 → h3 without h2 | |
| Duplicate heading ID | Multiple headings generate the same anchor | |
| Invalid footnote ref | ❌ ERROR | Reference to undefined footnote |
| Unused footnote | Footnote defined but never referenced | |
| Empty link target |
[text]() with no URL |
|
| Missing alt text | Images without description | |
| Empty link text | Links invisible to screen readers | |
| Non-descriptive link | "click here", "read more" links | |
| Missing code language | ℹ️ INFO | Fenced code without language tag |
| Table missing header | Screen readers need <th>
|
|
| Long alt text | Alt text > 125 characters |
These rules check selected accessibility-related source issues; they do not certify full WCAG compliance.
The Compose renderer provides basic reading semantics by default, independently of enableLinting:
These semantics are supplied by the shared Compose rendering path. Platform screen readers, including TalkBack and VoiceOver, still need validation on target devices. The preview includes a mixed-content example under “渲染与质量 → 无障碍阅读”.
| Spec | Status |
|---|---|
| CommonMark 0.31.2 | 652/652 (100%) ✅ |
| GFM 0.29 | Tables, task lists, strikethrough, autolinks ✅ |
| Markdown Extra | Footnotes, definition lists, abbreviations, fenced code ✅ |
| Area | Verified behavior |
|---|---|
| Incremental and streaming parsing | Matches independent full parsing across four flavours, including continuous edits and streamed prefixes |
| Asynchronous lifecycle | Regression coverage for full-text replacement, cancellation, configuration changes, and page disposal |
| Extreme inputs | Isolated-process checks pass for 512 nested quotes, million-character lines, dense delimiters, and large unclosed blocks |
| Regression checks | JVM tests and parser performance budgets pass; parser and renderer tests compile for JS and Wasm |
See the CI results for the current revision.
Configure which syntax features to enable:
// Strict CommonMark — no extensions
val doc = MarkdownParser(CommonMarkFlavour).parse(input)
// GFM — CommonMark + tables, strikethrough, autolinks
val doc = MarkdownParser(GFMFlavour).parse(input)
// Extended (default) — everything enabled
val doc = MarkdownParser().parse(input)
// One-shot HTML rendering
val html = HtmlRenderer.renderMarkdown(input, flavour = CommonMarkFlavour)MarkdownParserConfig lives in com.hrm.markdown.parser. Share one configuration across the parser,
Compose and HTML export:
import com.hrm.markdown.parser.MarkdownParserConfig
val config = MarkdownParserConfig(flavour = GFMFlavour, enableLinting = true)
val parser = MarkdownParser(config)
val document = parser.parse(input)
Markdown(markdown = input, parserConfig = config)
val html = MarkdownHtml.render(input, parserConfig = config)Configuration copies and protects the supplied emoji map; later changes to that map do not change an
existing parser session. Built-in flavours are immutable configurations. Use copy to replace settings
or with to append syntax starters or processors while retaining the other syntax settings.
Custom block transformations receive and return a block; the library handles incremental updates:
val flavour = CommonMarkFlavour.with(BlockPostProcessor { block ->
if (block is Heading) block.level = minOf(block.level, 3)
block
})
val doc = MarkdownParser(flavour).parse(input)BlockPostProcessor transforms independent blocks, returning the input, a replacement or null.
Use the separate PostProcessor { document -> ... } for document-wide transformations. Both run before
publication; callers manage no revisions, changed ranges or caches. A configuration can be shared by
independent parsers; stateful extension instances remain responsible for their own concurrency.
Markdown() renders a safe, cross-platform subset of HTML without a WebView:
Text with <strong>bold</strong>, <em>italic</em>,
<span style="color:red">styled text</span>, and <a href="https://example.com">a link</a>.<br>
The next line can also contain an <img src="https://example.com/icon.png" alt="icon">.Supported semantic tags are strong/b, em/i, del/s/strike, mark,
sup, sub, ins, u, code, kbd, span, a, br, and img. Inline HTML
comments are hidden. Formatting tags and br accept no attributes; span accepts only
fully supported style/class values; a accepts only href; and img accepts only
src, alt, title, width, and height. Supported span CSS is limited to colors,
background colors, font weight/style, and underline/line-through. Any unknown attribute,
CSS declaration, unsafe URL, mismatched tag, or unclosed tag causes that HTML source to
remain visible instead of being partially rendered or silently losing content.
Safe block fragments are supported for p, div, center, article, section, main,
header, footer, h1-h6, blockquote, hr, safe pre/code code blocks, and safe
ul/ol/li list structures. Containers can contain the safe inline tags above and use
align or the limited style="text-align:..." form with left, start, center,
right, or end. ol accepts an integer start attribute; ul and li accept no
attributes. pre can contain text directly or a single code child; code accepts only
class="language-..." or class="lang-..." to declare a language. left/right remain
physical directions, while start/end follow the current layout direction. Compose
routing is explicit by CommonMark HTML block type: comments (type 2) may be hidden, safe
type 6/7 fragments may be mapped, and safe type 1 pre code fragments may be mapped;
other type 1 fragments and types 3/4/5 stay raw. Malformed fragments, unknown attributes,
or unsupported structures such as tables, forms, scripts, and arbitrary CSS fall back
atomically to visible raw HTML.
A block tag must start on its own line according to CommonMark rules. In particular,
text <p>block</p> text is invalid block markup and remains inline source; put the p element
on its own line. HtmlRenderer/MarkdownHtml continue to pass all raw HTML through when exporting HTML.
Built-in Coil3 handles images automatically. Need custom rendering? Easy:
Markdown(
markdown = markdownText,
components = MarkdownComponents(image = { data, modifier ->
// data.url, data.altText, data.width, data.height available
AsyncImage(
model = data.url,
contentDescription = data.altText,
modifier = modifier,
)
}),
)Supports size specification in Markdown:

# Android
./gradlew :composeApp:assembleDebug
# Desktop (JVM)
./gradlew :composeApp:run
# Web (Wasm)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Web (JS)
./gradlew :composeApp:jsBrowserDevelopmentRun
# iOS — open iosApp/ in Xcode| # | Category | Supported / Listed |
|---|---|---|
| 1 | Headings | 17/17 (100%) |
| 2 | Paragraphs | 5/5 (100%) |
| 3 | Code Blocks | 17/17 (100%) |
| 4 | Block Quotes | 8/8 (100%) |
| 5 | Lists | 20/20 (100%) |
| 6 | Thematic Breaks | 6/6 (100%) |
| 7 | Tables (GFM) | 11/11 (100%) |
| 8 | HTML Blocks | 12/12 (100%) |
| 9 | Link References | 12/12 (100%) |
| 10 | Block Extensions | 85/85 (100%) |
| 11 | Emphasis | 13/13 (100%) |
| 12 | Strikethrough | 4/4 (100%) |
| 13 | Inline Code | 8/8 (100%) |
| 14 | Links | 27/27 (100%) |
| 15 | Images | 17/17 (100%) |
| 16 | Inline HTML | 10/10 (100%) |
| 17 | Escapes & Entities | 10/10 (100%) |
| 18 | Line Breaks | 5/5 (100%) |
| 19 | Inline Extensions | 50/50 (100%) |
| 20 | Streaming Engine | 27/27 (100%) |
| 21 | Character & Encoding | 10/10 (100%) |
| 22 | HTML Generator | 12/12 (100%) |
| 23 | Linting / WCAG | 19/19 (100%) |
| 24 | Directives | 8/8 (100%) |
| Total | 413/413 (100%) |
📖 Full details: PARSER_COVERAGE_ANALYSIS.md
markdown-renderer integrates the following external libraries for math, code highlighting, and diagrams:
| Capability | Maven modules used by this project | Repository |
|---|---|---|
| LaTeX math |
io.github.huarangmeng:latex-base, io.github.huarangmeng:latex-parser, io.github.huarangmeng:latex-renderer, io.github.huarangmeng:latex-fonts-katex
|
huarangmeng/latex |
| Code highlighting |
io.github.huarangmeng:codehighlight-parser, io.github.huarangmeng:codehighlight-render
|
huarangmeng/codehigh |
| Diagram blocks |
io.github.huarangmeng:diagram-core, io.github.huarangmeng:diagram-layout, io.github.huarangmeng:diagram-parser, io.github.huarangmeng:diagram-render
|
huarangmeng/diagram |
If you only need a subset of the rendering stack, you can depend on the corresponding upstream library directly.
MIT License · Copyright (c) 2026 huarangmeng
This project is licensed under the MIT License — see the LICENSE file for details.