
Markdown renderer with predictable AST, safe link/image defaults, extensible style model, admonitions, footnotes, syntax-highlighted code blocks, streaming-friendly debounced parsing, and pluggable image loading.
Compose Multiplatform Markdown renderer. Targets Android, iOS, Desktop (JVM), and wasmJs.
0.13.0
orca-core) and Compose renderer (orca-compose)orca-core
org.jetbrains:markdown (intellij-markdown, GFM flavour)orca-compose
OrcaDocument
OrcaStyle)orca-compose-material3
orca-compose
rememberOrcaMaterialStyle() without adding Material 3 to the base rendererorca-images-coil
orca-math-orcex (optional, Android / Desktop / iOS)
orca-core and orca-compose free from a bundled font or math enginesample-app
// Kotlin Multiplatform (commonMain)
implementation("ru.wertik:orca-core:0.13.0")
implementation("ru.wertik:orca-compose:0.13.0")
implementation("ru.wertik:orca-compose-material3:0.13.0") // optional Material 3 theme adapter
implementation("ru.wertik:orca-images-coil:0.13.0") // optional images
implementation("ru.wertik:orca-math-orcex:0.13.0") // optional multiplatform math rendererGradle resolves platform-specific artifacts automatically (orca-core-jvm, orca-compose-android, etc.).
import ru.wertik.orca.core.OrcaMarkdownParser
import ru.wertik.orca.core.OrcaParser
val parser: OrcaParser = OrcaMarkdownParser()
val document = parser.parse(markdown)
OrcaMarkdownParserusesorg.jetbrains:markdownand is available incommonMain(Android, iOS, Desktop, wasmJs).
val parser = OrcaMarkdownParser()
val document = parser.parseCached(
key = "message-42",
input = markdown,
)Use a stable key per message/item to avoid repeated AST rebuilds across recompositions and list reuse.
val parser = OrcaMarkdownParser(maxTreeDepth = 32)
val result = parser.parseWithDiagnostics(markdown)
val document = result.document
val warnings = result.diagnostics.warnings
val errors = result.diagnostics.errorsimport ru.wertik.orca.compose.Orca
import ru.wertik.orca.compose.OrcaRootLayout
import ru.wertik.orca.core.OrcaMarkdownParser
import androidx.compose.runtime.remember
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
parseCacheKey = "message-42",
rootLayout = OrcaRootLayout.COLUMN, // use when parent already controls scrolling
securityPolicy = OrcaSecurityPolicies.Default,
onLinkClick = { url ->
// open via your app policy
},
onParseDiagnostics = { diagnostics ->
// observe warnings/errors if needed
},
)import ru.wertik.orca.compose.Orca
Orca(
document = document,
)For token-by-token streaming (e.g. LLM responses), use OrcaStreamingState: it accepts deltas and publishes paced snapshots instead of forcing your UI to replace the entire string on every token.
val stream = rememberOrcaStreamingState(frameIntervalMs = 80)
val parser = remember {
OrcaIncrementalParserSession(OrcaMarkdownParser())
}
LaunchedEffect(messageId) {
parser.reset()
stream.clear()
responseChunks.collect { delta -> stream.append(delta) }
stream.finish()
}
Orca(
state = stream,
parser = parser,
parseCacheKey = "message-42",
)OrcaIncrementalParserSession reuses stable completed paragraph blocks and reparses only the active tail for ordinary prose streams. Rich constructs that can affect earlier content (lists, tables, headings, fences, definitions, footnotes, and HTML blocks) conservatively fall back to the full parser. The initial parse and subsequent parses run on Dispatchers.Default.
fun interface OrcaParser {
fun parse(input: String): OrcaDocument
fun parseWithDiagnostics(input: String): OrcaParseResult
fun parseCached(key: Any, input: String): OrcaDocument
fun parseCachedWithDiagnostics(key: Any, input: String): OrcaParseResult
}OrcaMarkdownParser options:
OrcaMarkdownParser(
maxTreeDepth = 64,
cacheSize = 64,
enableSuperscript = true, // set false to disable ^text^ parsing
enableSubscript = true, // set false to disable ~text~ parsing
onDepthLimitExceeded = { depth ->
// observe depth truncation if needed
},
)Diagnostics model:
data class OrcaParseResult(
val document: OrcaDocument,
val diagnostics: OrcaParseDiagnostics,
)---)> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION])Term + : Definition)<details>/<summary> — collapsible blocks)^text^)~text~)==text==)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, etc.)^[...]
\n):smile:, :rocket:, :fire:, etc.)*[ABBR]: Full Title)GFMFlavourDescriptor from org.jetbrains:markdown
https://example.com)[^label] and inline ^[...])--- ... ---)+++ ... +++)LazyColumn root for long documentsOrcaRootLayout.LAZY_COLUMN or OrcaRootLayout.COLUMN
Dispatchers.Default)[n]) to jump to definition↩) to return to source block[link](#heading-text) scrolls to the corresponding heading (auto-generated GitHub-style slugs)blockOverride parameterorca-compose displays fallback/alt text; supply imageContent and inlineImageContent only when image rendering is needed<details>/<summary> blocks rendered as collapsible sections<details open> for initially expanded stateOrcaDetailsStyle
<b>, <i>, <s>, <u>, <code>, <a>, <sup>, <sub>, <mark>, <kbd>, <br>, <p>, <h1>-<h6>, <li>, <hr>, <blockquote>, <pre>
&, <, >, ", , numeric —, ✔, etc.)<b><i></b></i> -- styles popped and re-pushed correctly)Use OrcaStyle as a single configuration object:
typographyinlinelayoutquotecodetablethematicBreakimageinlineImageadmonitiondefinitionListdetails// Automatically picks light or dark style based on system theme
val style = OrcaDefaults.adaptiveStyle() // @ComposableFor Material 3 apps, derive colors, typography, and shapes directly from the active theme:
import ru.wertik.orca.compose.material3.rememberOrcaMaterialStyle
val style = rememberOrcaMaterialStyle()Pass the same LazyListState to Orca and your scrollbar or external controls:
val listState = rememberLazyListState()
Orca(
document = document,
listState = listState,
style = rememberOrcaMaterialStyle(),
)import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ru.wertik.orca.compose.Orca
import ru.wertik.orca.compose.OrcaCodeBlockStyle
import ru.wertik.orca.compose.OrcaStyle
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
style = OrcaStyle(
code = OrcaCodeBlockStyle(
background = Color(0xFFF8F9FB),
borderColor = Color(0xFFD0D7DE),
borderWidth = 1.dp,
),
),
)http, https, mailto, and local #fragment targets.OrcaSecurityPolicy.For trusted content that should load remote images, opt into both URL permission and an image renderer. With the optional Coil module:
import ru.wertik.orca.images.coil.OrcaCoilImage
import ru.wertik.orca.images.coil.OrcaCoilInlineImage
Orca(
document = document,
securityPolicy = OrcaSecurityPolicies.RemoteImages,
imageContent = { url, description -> OrcaCoilImage(url, description, style) },
inlineImageContent = { url, description -> OrcaCoilInlineImage(url, description, style) },
)Custom policy example:
val policy = OrcaSecurityPolicies.byAllowedSchemes(
linkSchemes = setOf("https", "myapp"),
imageSchemes = setOf("https"),
allowRelativeLinks = true,
allowRelativeImages = true,
)
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
securityPolicy = policy,
)Always keep your own URL-opening policy in onLinkClick.
| Platform | orca-core | orca-compose | Parser |
|---|---|---|---|
| Android | commonMain + jvmMain | full | OrcaMarkdownParser |
| Desktop (JVM) | commonMain + jvmMain | full | OrcaMarkdownParser |
| iOS | commonMain | full | OrcaMarkdownParser |
| wasmJs (Web) | commonMain | full | OrcaMarkdownParser |
Override how specific block types are rendered:
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
blockOverride = mapOf(
OrcaBlock.CodeBlock::class to { block ->
val code = block as OrcaBlock.CodeBlock
MyCustomCodeBlock(code = code.code, language = code.language)
},
),
)orca-compose intentionally ships without an image/network stack. Add orca-images-coil for the provided Coil/Ktor slots, or provide your own slots:
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
securityPolicy = OrcaSecurityPolicies.RemoteImages,
imageContent = { url, contentDescription ->
GlideImage(model = url, contentDescription = contentDescription)
},
inlineImageContent = { url, contentDescription ->
GlideInlineImage(model = url, contentDescription = contentDescription)
},
)Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
style = OrcaStyle(
admonition = OrcaAdmonitionStyle(
collapsible = true,
collapsedByDefault = false,
),
),
)./gradlew --no-daemon --build-cache :orca-core:jvmTest :orca-compose:testDebugUnitTest :orca-compose-material3:testDebugUnitTest :sample-app:assembleDebugFor release-like check:
./gradlew --no-daemon --build-cache :sample-app:assembleRelease :sample-app:bundleRelease0.9.1
-alpha, -beta, -rc
LazyColumn no longer appear empty before gaining their real height and displacing scroll position.orca-math-orcex from the Android Canvas bridge to Orcex 0.4.0's Compose Multiplatform renderer for Android, Desktop, and supported iOS targets.Typeface convenience overloads so current Android applications can upgrade without rewriting their formula slots.orca-math-orcex; Compose UI remains supplied transitively by orca-compose.$...$ and display $$...$$ formulas with readable source fallback.orca-math-orcex for native Android Canvas math rendering; the STIX font remains opt-in.orca-compose into opt-in orca-images-coil.imageContent / inlineImageContent; without a loader, safe alt/fallback text remains visible.rememberOrcaStreamingState() accepts token deltas and publishes paced snapshots for chat rendering without caller-side full-string updates per token.OrcaIncrementalParserSession reuses completed prose blocks and safely falls back to full parsing for document-scoped/rich Markdown constructs.OrcaDefaults.darkStyle() now provides explicit light table body/header colors instead of inheriting black text.OrcaSecurityPolicies.RemoteImages or a custom scheme policy.Dispatchers.Default from the first composition onward.api dependencies.==highlight== syntax -- inline text highlight with configurable OrcaInlineStyle.highlight (yellow background by default)## My Heading -> id = "my-heading"), duplicate headings get -1, -2 suffixes[link](#heading-slug) clicks auto-scroll to the matching heading in both LAZY_COLUMN and COLUMN layouts#fragment URLs now pass security policy (previously blocked as schemeless)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, <code>, <u>, <s> tags in paragraphs now render with proper styles (previously stripped to plain text)<kbd> tag -- keyboard input tag rendered with monospace font + subtle background in both block and inline HTML—, ✔ and all decimal/hex character references decoded correctly<summary>**bold** text</summary> now renders rich inline formatting (was plain text)String(IntArray)
<details>/<summary> support -- collapsible blocks with animated expand/collapse, <details open>, nested markdown contentwhen (painter.state) with slot-based loading/error/success parametersOrcaParserCache now parses outside the lock; concurrent callers no longer block each other (eliminates ANR risk on main thread)<b><i></b></i> is handled correctly by scanning the stack and re-pushing intervening stylesTableRowNode uses rememberUpdatedState for callbacks, preventing unnecessary AnnotatedString rebuilds on every recompositiononParseDiagnostics
OrcaBlockNode enforces MAX_RENDER_DEPTH = 32 to prevent stack overflow on deeply nested markdown from custom parsersstableHash samples 256 characters (was 128) and folds in tail content for strings >256 chars, reducing LazyColumn key collisions for code blocks with identical importsMIT. See LICENSE.
Compose Multiplatform Markdown renderer. Targets Android, iOS, Desktop (JVM), and wasmJs.
0.13.0
orca-core) and Compose renderer (orca-compose)orca-core
org.jetbrains:markdown (intellij-markdown, GFM flavour)orca-compose
OrcaDocument
OrcaStyle)orca-compose-material3
orca-compose
rememberOrcaMaterialStyle() without adding Material 3 to the base rendererorca-images-coil
orca-math-orcex (optional, Android / Desktop / iOS)
orca-core and orca-compose free from a bundled font or math enginesample-app
// Kotlin Multiplatform (commonMain)
implementation("ru.wertik:orca-core:0.13.0")
implementation("ru.wertik:orca-compose:0.13.0")
implementation("ru.wertik:orca-compose-material3:0.13.0") // optional Material 3 theme adapter
implementation("ru.wertik:orca-images-coil:0.13.0") // optional images
implementation("ru.wertik:orca-math-orcex:0.13.0") // optional multiplatform math rendererGradle resolves platform-specific artifacts automatically (orca-core-jvm, orca-compose-android, etc.).
import ru.wertik.orca.core.OrcaMarkdownParser
import ru.wertik.orca.core.OrcaParser
val parser: OrcaParser = OrcaMarkdownParser()
val document = parser.parse(markdown)
OrcaMarkdownParserusesorg.jetbrains:markdownand is available incommonMain(Android, iOS, Desktop, wasmJs).
val parser = OrcaMarkdownParser()
val document = parser.parseCached(
key = "message-42",
input = markdown,
)Use a stable key per message/item to avoid repeated AST rebuilds across recompositions and list reuse.
val parser = OrcaMarkdownParser(maxTreeDepth = 32)
val result = parser.parseWithDiagnostics(markdown)
val document = result.document
val warnings = result.diagnostics.warnings
val errors = result.diagnostics.errorsimport ru.wertik.orca.compose.Orca
import ru.wertik.orca.compose.OrcaRootLayout
import ru.wertik.orca.core.OrcaMarkdownParser
import androidx.compose.runtime.remember
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
parseCacheKey = "message-42",
rootLayout = OrcaRootLayout.COLUMN, // use when parent already controls scrolling
securityPolicy = OrcaSecurityPolicies.Default,
onLinkClick = { url ->
// open via your app policy
},
onParseDiagnostics = { diagnostics ->
// observe warnings/errors if needed
},
)import ru.wertik.orca.compose.Orca
Orca(
document = document,
)For token-by-token streaming (e.g. LLM responses), use OrcaStreamingState: it accepts deltas and publishes paced snapshots instead of forcing your UI to replace the entire string on every token.
val stream = rememberOrcaStreamingState(frameIntervalMs = 80)
val parser = remember {
OrcaIncrementalParserSession(OrcaMarkdownParser())
}
LaunchedEffect(messageId) {
parser.reset()
stream.clear()
responseChunks.collect { delta -> stream.append(delta) }
stream.finish()
}
Orca(
state = stream,
parser = parser,
parseCacheKey = "message-42",
)OrcaIncrementalParserSession reuses stable completed paragraph blocks and reparses only the active tail for ordinary prose streams. Rich constructs that can affect earlier content (lists, tables, headings, fences, definitions, footnotes, and HTML blocks) conservatively fall back to the full parser. The initial parse and subsequent parses run on Dispatchers.Default.
fun interface OrcaParser {
fun parse(input: String): OrcaDocument
fun parseWithDiagnostics(input: String): OrcaParseResult
fun parseCached(key: Any, input: String): OrcaDocument
fun parseCachedWithDiagnostics(key: Any, input: String): OrcaParseResult
}OrcaMarkdownParser options:
OrcaMarkdownParser(
maxTreeDepth = 64,
cacheSize = 64,
enableSuperscript = true, // set false to disable ^text^ parsing
enableSubscript = true, // set false to disable ~text~ parsing
onDepthLimitExceeded = { depth ->
// observe depth truncation if needed
},
)Diagnostics model:
data class OrcaParseResult(
val document: OrcaDocument,
val diagnostics: OrcaParseDiagnostics,
)---)> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION])Term + : Definition)<details>/<summary> — collapsible blocks)^text^)~text~)==text==)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, etc.)^[...]
\n):smile:, :rocket:, :fire:, etc.)*[ABBR]: Full Title)GFMFlavourDescriptor from org.jetbrains:markdown
https://example.com)[^label] and inline ^[...])--- ... ---)+++ ... +++)LazyColumn root for long documentsOrcaRootLayout.LAZY_COLUMN or OrcaRootLayout.COLUMN
Dispatchers.Default)[n]) to jump to definition↩) to return to source block[link](#heading-text) scrolls to the corresponding heading (auto-generated GitHub-style slugs)blockOverride parameterorca-compose displays fallback/alt text; supply imageContent and inlineImageContent only when image rendering is needed<details>/<summary> blocks rendered as collapsible sections<details open> for initially expanded stateOrcaDetailsStyle
<b>, <i>, <s>, <u>, <code>, <a>, <sup>, <sub>, <mark>, <kbd>, <br>, <p>, <h1>-<h6>, <li>, <hr>, <blockquote>, <pre>
&, <, >, ", , numeric —, ✔, etc.)<b><i></b></i> -- styles popped and re-pushed correctly)Use OrcaStyle as a single configuration object:
typographyinlinelayoutquotecodetablethematicBreakimageinlineImageadmonitiondefinitionListdetails// Automatically picks light or dark style based on system theme
val style = OrcaDefaults.adaptiveStyle() // @ComposableFor Material 3 apps, derive colors, typography, and shapes directly from the active theme:
import ru.wertik.orca.compose.material3.rememberOrcaMaterialStyle
val style = rememberOrcaMaterialStyle()Pass the same LazyListState to Orca and your scrollbar or external controls:
val listState = rememberLazyListState()
Orca(
document = document,
listState = listState,
style = rememberOrcaMaterialStyle(),
)import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ru.wertik.orca.compose.Orca
import ru.wertik.orca.compose.OrcaCodeBlockStyle
import ru.wertik.orca.compose.OrcaStyle
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
style = OrcaStyle(
code = OrcaCodeBlockStyle(
background = Color(0xFFF8F9FB),
borderColor = Color(0xFFD0D7DE),
borderWidth = 1.dp,
),
),
)http, https, mailto, and local #fragment targets.OrcaSecurityPolicy.For trusted content that should load remote images, opt into both URL permission and an image renderer. With the optional Coil module:
import ru.wertik.orca.images.coil.OrcaCoilImage
import ru.wertik.orca.images.coil.OrcaCoilInlineImage
Orca(
document = document,
securityPolicy = OrcaSecurityPolicies.RemoteImages,
imageContent = { url, description -> OrcaCoilImage(url, description, style) },
inlineImageContent = { url, description -> OrcaCoilInlineImage(url, description, style) },
)Custom policy example:
val policy = OrcaSecurityPolicies.byAllowedSchemes(
linkSchemes = setOf("https", "myapp"),
imageSchemes = setOf("https"),
allowRelativeLinks = true,
allowRelativeImages = true,
)
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
securityPolicy = policy,
)Always keep your own URL-opening policy in onLinkClick.
| Platform | orca-core | orca-compose | Parser |
|---|---|---|---|
| Android | commonMain + jvmMain | full | OrcaMarkdownParser |
| Desktop (JVM) | commonMain + jvmMain | full | OrcaMarkdownParser |
| iOS | commonMain | full | OrcaMarkdownParser |
| wasmJs (Web) | commonMain | full | OrcaMarkdownParser |
Override how specific block types are rendered:
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
blockOverride = mapOf(
OrcaBlock.CodeBlock::class to { block ->
val code = block as OrcaBlock.CodeBlock
MyCustomCodeBlock(code = code.code, language = code.language)
},
),
)orca-compose intentionally ships without an image/network stack. Add orca-images-coil for the provided Coil/Ktor slots, or provide your own slots:
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
securityPolicy = OrcaSecurityPolicies.RemoteImages,
imageContent = { url, contentDescription ->
GlideImage(model = url, contentDescription = contentDescription)
},
inlineImageContent = { url, contentDescription ->
GlideInlineImage(model = url, contentDescription = contentDescription)
},
)Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
style = OrcaStyle(
admonition = OrcaAdmonitionStyle(
collapsible = true,
collapsedByDefault = false,
),
),
)./gradlew --no-daemon --build-cache :orca-core:jvmTest :orca-compose:testDebugUnitTest :orca-compose-material3:testDebugUnitTest :sample-app:assembleDebugFor release-like check:
./gradlew --no-daemon --build-cache :sample-app:assembleRelease :sample-app:bundleRelease0.9.1
-alpha, -beta, -rc
LazyColumn no longer appear empty before gaining their real height and displacing scroll position.orca-math-orcex from the Android Canvas bridge to Orcex 0.4.0's Compose Multiplatform renderer for Android, Desktop, and supported iOS targets.Typeface convenience overloads so current Android applications can upgrade without rewriting their formula slots.orca-math-orcex; Compose UI remains supplied transitively by orca-compose.$...$ and display $$...$$ formulas with readable source fallback.orca-math-orcex for native Android Canvas math rendering; the STIX font remains opt-in.orca-compose into opt-in orca-images-coil.imageContent / inlineImageContent; without a loader, safe alt/fallback text remains visible.rememberOrcaStreamingState() accepts token deltas and publishes paced snapshots for chat rendering without caller-side full-string updates per token.OrcaIncrementalParserSession reuses completed prose blocks and safely falls back to full parsing for document-scoped/rich Markdown constructs.OrcaDefaults.darkStyle() now provides explicit light table body/header colors instead of inheriting black text.OrcaSecurityPolicies.RemoteImages or a custom scheme policy.Dispatchers.Default from the first composition onward.api dependencies.==highlight== syntax -- inline text highlight with configurable OrcaInlineStyle.highlight (yellow background by default)## My Heading -> id = "my-heading"), duplicate headings get -1, -2 suffixes[link](#heading-slug) clicks auto-scroll to the matching heading in both LAZY_COLUMN and COLUMN layouts#fragment URLs now pass security policy (previously blocked as schemeless)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, <code>, <u>, <s> tags in paragraphs now render with proper styles (previously stripped to plain text)<kbd> tag -- keyboard input tag rendered with monospace font + subtle background in both block and inline HTML—, ✔ and all decimal/hex character references decoded correctly<summary>**bold** text</summary> now renders rich inline formatting (was plain text)String(IntArray)
<details>/<summary> support -- collapsible blocks with animated expand/collapse, <details open>, nested markdown contentwhen (painter.state) with slot-based loading/error/success parametersOrcaParserCache now parses outside the lock; concurrent callers no longer block each other (eliminates ANR risk on main thread)<b><i></b></i> is handled correctly by scanning the stack and re-pushing intervening stylesTableRowNode uses rememberUpdatedState for callbacks, preventing unnecessary AnnotatedString rebuilds on every recompositiononParseDiagnostics
OrcaBlockNode enforces MAX_RENDER_DEPTH = 32 to prevent stack overflow on deeply nested markdown from custom parsersstableHash samples 256 characters (was 128) and folds in tail content for strings >256 chars, reducing LazyColumn key collisions for code blocks with identical importsMIT. See LICENSE.