
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.32.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(density = …) 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
orca-benchmarks (not published)
// Kotlin Multiplatform (commonMain)
implementation("ru.wertik:orca-core:0.32.0")
implementation("ru.wertik:orca-compose:0.32.0")
implementation("ru.wertik:orca-compose-material3:0.32.0") // optional Material 3 theme adapter
implementation("ru.wertik:orca-images-coil:0.32.0") // optional images
implementation("ru.wertik:orca-math-orcex:0.32.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 freezes completed blocks and reparses only the active tail. Segments are cut at blank lines and at completed top-level structures, so a column-zero ``` block is frozen the moment its closing fence arrives, and everything before an open fence is frozen while the block is still streaming. While a fence is open the tail is a single code block, which the session rebuilds directly — after verifying once against the delegate that this matches — instead of re-parsing the growing block on every token. Constructs whose meaning depends on the whole document (link/footnote/abbreviation definitions, inline footnotes, front matter, definition lists) fall back to the full parser, as do cuts that would land inside an HTML, <details>, or display-math region. The initial parse and subsequent parses run on `Dispatchers.Default`.
On a typical assistant answer (prose, a 60-line fence, a list, a second fence) streamed in 16-character chunks, the session is ~7x faster than re-parsing every prefix and feeds the parser under 2% of the characters. session.stats reports fullParses, incrementalParses, reusedStableBlocks, and codeFenceFastPaths.
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
maxInlineBracketDepth = 512, // unmatched `[` per block before it is kept as plain text
maxBlockNestingDepth = 128, // quote/list nesting per block before it is kept as plain text
onDepthLimitExceeded = { depth ->
// observe depth truncation if needed
},
)maxInlineBracketDepth bounds the inline scanner. Resolving link openers backtracks over
every unmatched [ in a block, which is quadratic: 25 600 of them in one paragraph used to
look like a deadlock. maxBlockNestingDepth does the same for the block parser, which
recurses once per quote marker or list level and can exhaust the stack before any AST depth
limit is reached.
A block above either limit is kept verbatim as a plain text paragraph and reported as
OrcaParseWarning.InlineBracketLimitExceeded / BlockNestingLimitExceeded; the rest of the
document parses normally. Fenced code, indented code, and $$ math are never counted.
Diagnostics model:
data class OrcaParseResult(
val document: OrcaDocument,
val diagnostics: OrcaParseDiagnostics,
)Document utilities (orca-core):
val document = parser.parse(markdown)
document.tableOfContents(maxLevel = 2) // headings with anchors
document.stats() // words, reading time, block and task counts
document.findMatches("streaming") // hits with top-level block indices
document.plainText() // markup-free projection$$...$$)---)<img> and <figure>/<figcaption> through the image slot and URL policy)> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION])Term + : Definition)<details>/<summary> — collapsible blocks)^text^)~text~)==text==)++text++)$...$)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, etc.)<img> through the same inline image slot and URL policy)^[...]
\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 parameterinlineOverride
onTaskToggle to receive checkbox taps (document-order index + requested state) and update your source; rendering stays statelesstaskCheckboxContent
OrcaDocument.tableOfContents() + orcaHeadingBlockIndex() map headings to lazy-list indices for scroll-to-section UIsstreamingCursor glyph rendered after the last block while a response streamsorca-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>
<img> and <figure>/<figcaption> blocks route through OrcaSecurityPolicy and imageContent
<img> tags route through OrcaSecurityPolicy and inlineImageContent
&, <, >, ", , numeric —, ✔, etc.)<b><i></b></i> -- styles popped and re-pushed correctly)Use OrcaStyle as a single configuration object:
typographyinlinelayoutquotecodetablethematicBreakimageinlineImageadmonitiondefinitionListdetailstaskheadingRuleSince 0.30, every built-in style is generated from flat color tokens. There is no elevation,
gradient, or shadow anywhere in the render tree: structure comes from solid fills, one-pixel
outlines, and typography.
import ru.wertik.orca.compose.OrcaDensity
import ru.wertik.orca.compose.OrcaPalettes
import ru.wertik.orca.compose.orcaFlatStyle
val style = orcaFlatStyle(
palette = OrcaPalettes.FlatDark, // FlatLight, FlatDark, ContrastLight, ContrastDark
density = OrcaDensity.COMPACT, // COMPACT, COMFORTABLE, SPACIOUS
headingRuleLevels = setOf(1, 2), // one-pixel rules under H1/H2
)OrcaPalette is the token surface: background, surface, surfaceMuted, surfaceStrong,
outline, outlineMuted, text, textMuted, accent, onAccent, accentSurface,
highlight, searchMatch, plus a syntax palette for code and a signal palette with one color
per admonition type. Copy a preset to brand it:
val brand = OrcaPalettes.FlatLight.copy(accent = Color(0xFF1F5FA8))Density scales spacing and padding only; text metrics stay identical across the three modes.
// Automatically picks the flat light or flat dark style based on the system theme
val style = OrcaDefaults.adaptiveStyle() // @Composable
val dense = OrcaDefaults.adaptiveStyle(OrcaDensity.COMPACT) // @Composable
val a11y = OrcaDefaults.adaptiveContrastStyle() // @ComposableOrcaDefaults.legacyLightStyle() and OrcaDefaults.legacyDarkStyle() keep the pre-0.30 visuals
for applications that pinned screenshots to them.
For Material 3 apps, derive colors, typography, and shapes directly from the active theme:
import ru.wertik.orca.compose.material3.rememberOrcaMaterialStyle
val style = rememberOrcaMaterialStyle(density = OrcaDensity.COMFORTABLE)import ru.wertik.orca.compose.OrcaTextHighlight
import ru.wertik.orca.core.findMatches
val matches = document.findMatches(query)
Orca(
document = document,
listState = listState,
highlight = OrcaTextHighlight(query),
)
// matches[i].blockIndex maps directly to listState.animateScrollToItem(...)Matches are shaded with OrcaInlineStyle.searchMatch across headings, paragraphs, list items,
table cells, definition terms, details summaries, and footnote bodies. Code blocks keep their
syntax colors.
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)
},
),
)Replace exact inline node classes with custom annotated text. The same map is threaded through paragraphs, headings, tables, definition terms, and details summaries.
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
inlineOverride = mapOf(
OrcaInline.Abbreviation::class to { inline ->
val abbreviation = inline as OrcaInline.Abbreviation
AnnotatedString("${abbreviation.text} (${abbreviation.title})")
},
),
)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:assembleDebugParser performance is guarded separately, by scaling checks rather than absolute timings, so the same limits hold on a laptop and on a CI runner:
./gradlew :orca-benchmarks:run --args="--check" # full run, fails on a regression
./gradlew :orca-benchmarks:run --args="--quick" # shorter local sanity run
./gradlew :orca-benchmarks:run --args="--check --markdown report.md --json report.json"For release-like check:
./gradlew --no-daemon --build-cache :sample-app:assembleRelease :sample-app:bundleRelease0.9.1
-alpha, -beta, -rc
A release can be cut three ways, all of which validate the version against orcaVersion and end
with a real 0.x.y tag on the released commit:
0.32.0;release/0.32.0 (the workflow creates the tag and deletes the branch afterwards);publish = true.A manual run without publish is a build-and-test dry run.
++underline++ / ^sup^ / ~sub~ / ==highlight== pattern was compiled from scratch for every text node, and the admonition and code-span patterns once per block. They are now compiled once, and every inline rewrite pass first checks whether the node can possibly contain what it looks for, returning the list it was given instead of rebuilding an identical one. Tree mapping of a 1.3 MB document went from 366 ms to 49 ms, a full parse from 488 ms to 172 ms, and Orca's cost over the upstream parser from 5.0x to 1.8x. AST output is byte-identical.maxBlockNestingDepth (default 128) bounds block-parser recursion. Quote markers and list indentation past the limit keep the block as a plain text paragraph and report OrcaParseWarning.BlockNestingLimitExceeded. > x 25 600 went from an out-of-memory or stack overflow (depending on stack size) to 33 ms, and 4 096 nested list levels from over 20 s to 137 ms. Indented code and fenced content are never counted, and the existing maxTreeDepth truncation behaviour is untouched.extractDefinitionLists() locates definition lines up front and only probes the one line that can open a list, instead of probing every line. A document that is one long paragraph (the common case) went from quadratic to linear: 8 000 lines now take 2 ms instead of 4.4 s, and a full parse of a 4 000-line document is ~5x faster.maxInlineBracketDepth (default 512) bounds the quadratic link-opener backtracking. A block with more unmatched [ is kept as a plain text paragraph and reported via OrcaParseWarning.InlineBracketLimitExceeded. [ x 25 600 went from an apparent hang to 2 ms; fenced code and display math are never affected.``` line with backticks in its info string is no longer treated as a fence opener, a list starting part way into a segment now blocks a cut after it, and cuts are refused where the delegate's <details>/$$ pre-passes would still be mid-region. Each of these could make a streamed prefix differ from a full parse.orca-benchmarks module measures parsing, guarding, and streaming, and fails the build on scaling regressions (ratio based, so the limits hold on any machine). CI publishes the table as a job summary and keeps the report as an artifact.OrcaPalette, OrcaPalettes (flat light/dark plus high-contrast light/dark), OrcaSyntaxPalette, OrcaSignalPalette, and orcaFlatStyle() build a complete OrcaStyle from tokens. No gradients, shadows, or elevation overlays exist in the render tree.OrcaDensity.COMPACT | COMFORTABLE | SPACIOUS scales spacing and padding without touching text metrics. Accepted by orcaFlatStyle, OrcaDefaults.*Style(), and rememberOrcaMaterialStyle().OrcaDefaults.lightStyle() / darkStyle() now return the flat styles, and adaptiveContrastStyle() is available for accessibility surfaces. legacyLightStyle() / legacyDarkStyle() preserve the pre-0.30 look.OrcaHeadingRuleStyle draws a one-pixel rule under selected heading levels (H1/H2 by default in flat styles).OrcaDocument.findMatches() / countMatches() with case, whole-word, limit, and snippet options; each match carries its top-level block index and nearest heading anchor.OrcaTextHighlight on every Orca overload shades matches with OrcaInlineStyle.searchMatch across all inline surfaces.OrcaDocument.stats() returns words, characters, reading time, per-block-type counts, and task progress in one pass.OrcaDocument.plainText(), OrcaBlock.plainText(), and List<OrcaInline>.plainText() are public.rememberOrcaMaterialStyle() maps the color scheme into an OrcaPalette via OrcaDefaults.materialPalette() and builds the style through orcaFlatStyle, with density and headingRules options.publish = true), which validates the version, creates the tag on the built commit, and publishes.<img> and <figure>/<figcaption> blocks use the existing image slots and URL policy; inline <img> uses the inline image slot.Orca overload accepts an exact-class inlineOverride map returning AnnotatedString content.taskCheckboxContent allows full replacement.OrcaIncrementalParserSession now freezes a growing prefix of blank-line separated segments (headings, closed code fences, lists, quotes, admonitions, tables, thematic breaks) instead of plain paragraphs only. Only the active tail is re-parsed per update; heading anchor slugs are re-derived so duplicate titles keep full-parse numbering. Verified by prefix-equivalence property tests against the full parser.LAZY_COLUMN — the default lazy root layout is now wrapped in a SelectionContainer, matching the COLUMN mode.OrcaDocument.tableOfContents() in orca-core plus orcaHeadingBlockIndex() in orca-compose for scroll-to-heading UIs on top of LazyListState.streamingCursor glyph on all Orca overloads; the streaming overload shows it only while OrcaStreamingState.isStreaming. Applied to the parsed document, keeping incremental sessions append-only.OrcaAdmonitionStyle (showIcons, per-type icon strings).\textcolor, \color), framed results (\boxed) and stacked annotations (\overset/\underset), plus wasmJs artifacts of the Orcex runtime.++text++ syntax produces OrcaInline.Underline, styled via OrcaInlineStyle.underline.) now renders as a caption below block images; configurable via OrcaImageStyle.showCaption, captionText, and captionSpacing.onTaskToggle callback on all Orca overloads makes - [ ] checkboxes tappable; the host receives the document-order task index and requested state. Rendering stays stateless and dependency-free.<mark>, <kbd>, <u>/<ins>, <sup>, and <sub> now follow OrcaStyle instead of hardcoded light-theme colors, fixing unreadable spans in dark themes. OrcaInlineStyle gains underline and kbd fields.materialStyle() maps the new kbd and image-caption styles to color-scheme tokens.enableEdgeToEdge system-bar styles on toggle, and ships a values-night window background.orca-compose-material3 module with rememberOrcaMaterialStyle() deriving an OrcaStyle from the active MaterialTheme.LazyListState for external scroll control.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.32.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(density = …) 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
orca-benchmarks (not published)
// Kotlin Multiplatform (commonMain)
implementation("ru.wertik:orca-core:0.32.0")
implementation("ru.wertik:orca-compose:0.32.0")
implementation("ru.wertik:orca-compose-material3:0.32.0") // optional Material 3 theme adapter
implementation("ru.wertik:orca-images-coil:0.32.0") // optional images
implementation("ru.wertik:orca-math-orcex:0.32.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 freezes completed blocks and reparses only the active tail. Segments are cut at blank lines and at completed top-level structures, so a column-zero ``` block is frozen the moment its closing fence arrives, and everything before an open fence is frozen while the block is still streaming. While a fence is open the tail is a single code block, which the session rebuilds directly — after verifying once against the delegate that this matches — instead of re-parsing the growing block on every token. Constructs whose meaning depends on the whole document (link/footnote/abbreviation definitions, inline footnotes, front matter, definition lists) fall back to the full parser, as do cuts that would land inside an HTML, <details>, or display-math region. The initial parse and subsequent parses run on `Dispatchers.Default`.
On a typical assistant answer (prose, a 60-line fence, a list, a second fence) streamed in 16-character chunks, the session is ~7x faster than re-parsing every prefix and feeds the parser under 2% of the characters. session.stats reports fullParses, incrementalParses, reusedStableBlocks, and codeFenceFastPaths.
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
maxInlineBracketDepth = 512, // unmatched `[` per block before it is kept as plain text
maxBlockNestingDepth = 128, // quote/list nesting per block before it is kept as plain text
onDepthLimitExceeded = { depth ->
// observe depth truncation if needed
},
)maxInlineBracketDepth bounds the inline scanner. Resolving link openers backtracks over
every unmatched [ in a block, which is quadratic: 25 600 of them in one paragraph used to
look like a deadlock. maxBlockNestingDepth does the same for the block parser, which
recurses once per quote marker or list level and can exhaust the stack before any AST depth
limit is reached.
A block above either limit is kept verbatim as a plain text paragraph and reported as
OrcaParseWarning.InlineBracketLimitExceeded / BlockNestingLimitExceeded; the rest of the
document parses normally. Fenced code, indented code, and $$ math are never counted.
Diagnostics model:
data class OrcaParseResult(
val document: OrcaDocument,
val diagnostics: OrcaParseDiagnostics,
)Document utilities (orca-core):
val document = parser.parse(markdown)
document.tableOfContents(maxLevel = 2) // headings with anchors
document.stats() // words, reading time, block and task counts
document.findMatches("streaming") // hits with top-level block indices
document.plainText() // markup-free projection$$...$$)---)<img> and <figure>/<figcaption> through the image slot and URL policy)> [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION])Term + : Definition)<details>/<summary> — collapsible blocks)^text^)~text~)==text==)++text++)$...$)<kbd>, <mark>, <b>, <i>, <sup>, <sub>, etc.)<img> through the same inline image slot and URL policy)^[...]
\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 parameterinlineOverride
onTaskToggle to receive checkbox taps (document-order index + requested state) and update your source; rendering stays statelesstaskCheckboxContent
OrcaDocument.tableOfContents() + orcaHeadingBlockIndex() map headings to lazy-list indices for scroll-to-section UIsstreamingCursor glyph rendered after the last block while a response streamsorca-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>
<img> and <figure>/<figcaption> blocks route through OrcaSecurityPolicy and imageContent
<img> tags route through OrcaSecurityPolicy and inlineImageContent
&, <, >, ", , numeric —, ✔, etc.)<b><i></b></i> -- styles popped and re-pushed correctly)Use OrcaStyle as a single configuration object:
typographyinlinelayoutquotecodetablethematicBreakimageinlineImageadmonitiondefinitionListdetailstaskheadingRuleSince 0.30, every built-in style is generated from flat color tokens. There is no elevation,
gradient, or shadow anywhere in the render tree: structure comes from solid fills, one-pixel
outlines, and typography.
import ru.wertik.orca.compose.OrcaDensity
import ru.wertik.orca.compose.OrcaPalettes
import ru.wertik.orca.compose.orcaFlatStyle
val style = orcaFlatStyle(
palette = OrcaPalettes.FlatDark, // FlatLight, FlatDark, ContrastLight, ContrastDark
density = OrcaDensity.COMPACT, // COMPACT, COMFORTABLE, SPACIOUS
headingRuleLevels = setOf(1, 2), // one-pixel rules under H1/H2
)OrcaPalette is the token surface: background, surface, surfaceMuted, surfaceStrong,
outline, outlineMuted, text, textMuted, accent, onAccent, accentSurface,
highlight, searchMatch, plus a syntax palette for code and a signal palette with one color
per admonition type. Copy a preset to brand it:
val brand = OrcaPalettes.FlatLight.copy(accent = Color(0xFF1F5FA8))Density scales spacing and padding only; text metrics stay identical across the three modes.
// Automatically picks the flat light or flat dark style based on the system theme
val style = OrcaDefaults.adaptiveStyle() // @Composable
val dense = OrcaDefaults.adaptiveStyle(OrcaDensity.COMPACT) // @Composable
val a11y = OrcaDefaults.adaptiveContrastStyle() // @ComposableOrcaDefaults.legacyLightStyle() and OrcaDefaults.legacyDarkStyle() keep the pre-0.30 visuals
for applications that pinned screenshots to them.
For Material 3 apps, derive colors, typography, and shapes directly from the active theme:
import ru.wertik.orca.compose.material3.rememberOrcaMaterialStyle
val style = rememberOrcaMaterialStyle(density = OrcaDensity.COMFORTABLE)import ru.wertik.orca.compose.OrcaTextHighlight
import ru.wertik.orca.core.findMatches
val matches = document.findMatches(query)
Orca(
document = document,
listState = listState,
highlight = OrcaTextHighlight(query),
)
// matches[i].blockIndex maps directly to listState.animateScrollToItem(...)Matches are shaded with OrcaInlineStyle.searchMatch across headings, paragraphs, list items,
table cells, definition terms, details summaries, and footnote bodies. Code blocks keep their
syntax colors.
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)
},
),
)Replace exact inline node classes with custom annotated text. The same map is threaded through paragraphs, headings, tables, definition terms, and details summaries.
Orca(
markdown = markdown,
parser = remember { OrcaMarkdownParser() },
inlineOverride = mapOf(
OrcaInline.Abbreviation::class to { inline ->
val abbreviation = inline as OrcaInline.Abbreviation
AnnotatedString("${abbreviation.text} (${abbreviation.title})")
},
),
)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:assembleDebugParser performance is guarded separately, by scaling checks rather than absolute timings, so the same limits hold on a laptop and on a CI runner:
./gradlew :orca-benchmarks:run --args="--check" # full run, fails on a regression
./gradlew :orca-benchmarks:run --args="--quick" # shorter local sanity run
./gradlew :orca-benchmarks:run --args="--check --markdown report.md --json report.json"For release-like check:
./gradlew --no-daemon --build-cache :sample-app:assembleRelease :sample-app:bundleRelease0.9.1
-alpha, -beta, -rc
A release can be cut three ways, all of which validate the version against orcaVersion and end
with a real 0.x.y tag on the released commit:
0.32.0;release/0.32.0 (the workflow creates the tag and deletes the branch afterwards);publish = true.A manual run without publish is a build-and-test dry run.
++underline++ / ^sup^ / ~sub~ / ==highlight== pattern was compiled from scratch for every text node, and the admonition and code-span patterns once per block. They are now compiled once, and every inline rewrite pass first checks whether the node can possibly contain what it looks for, returning the list it was given instead of rebuilding an identical one. Tree mapping of a 1.3 MB document went from 366 ms to 49 ms, a full parse from 488 ms to 172 ms, and Orca's cost over the upstream parser from 5.0x to 1.8x. AST output is byte-identical.maxBlockNestingDepth (default 128) bounds block-parser recursion. Quote markers and list indentation past the limit keep the block as a plain text paragraph and report OrcaParseWarning.BlockNestingLimitExceeded. > x 25 600 went from an out-of-memory or stack overflow (depending on stack size) to 33 ms, and 4 096 nested list levels from over 20 s to 137 ms. Indented code and fenced content are never counted, and the existing maxTreeDepth truncation behaviour is untouched.extractDefinitionLists() locates definition lines up front and only probes the one line that can open a list, instead of probing every line. A document that is one long paragraph (the common case) went from quadratic to linear: 8 000 lines now take 2 ms instead of 4.4 s, and a full parse of a 4 000-line document is ~5x faster.maxInlineBracketDepth (default 512) bounds the quadratic link-opener backtracking. A block with more unmatched [ is kept as a plain text paragraph and reported via OrcaParseWarning.InlineBracketLimitExceeded. [ x 25 600 went from an apparent hang to 2 ms; fenced code and display math are never affected.``` line with backticks in its info string is no longer treated as a fence opener, a list starting part way into a segment now blocks a cut after it, and cuts are refused where the delegate's <details>/$$ pre-passes would still be mid-region. Each of these could make a streamed prefix differ from a full parse.orca-benchmarks module measures parsing, guarding, and streaming, and fails the build on scaling regressions (ratio based, so the limits hold on any machine). CI publishes the table as a job summary and keeps the report as an artifact.OrcaPalette, OrcaPalettes (flat light/dark plus high-contrast light/dark), OrcaSyntaxPalette, OrcaSignalPalette, and orcaFlatStyle() build a complete OrcaStyle from tokens. No gradients, shadows, or elevation overlays exist in the render tree.OrcaDensity.COMPACT | COMFORTABLE | SPACIOUS scales spacing and padding without touching text metrics. Accepted by orcaFlatStyle, OrcaDefaults.*Style(), and rememberOrcaMaterialStyle().OrcaDefaults.lightStyle() / darkStyle() now return the flat styles, and adaptiveContrastStyle() is available for accessibility surfaces. legacyLightStyle() / legacyDarkStyle() preserve the pre-0.30 look.OrcaHeadingRuleStyle draws a one-pixel rule under selected heading levels (H1/H2 by default in flat styles).OrcaDocument.findMatches() / countMatches() with case, whole-word, limit, and snippet options; each match carries its top-level block index and nearest heading anchor.OrcaTextHighlight on every Orca overload shades matches with OrcaInlineStyle.searchMatch across all inline surfaces.OrcaDocument.stats() returns words, characters, reading time, per-block-type counts, and task progress in one pass.OrcaDocument.plainText(), OrcaBlock.plainText(), and List<OrcaInline>.plainText() are public.rememberOrcaMaterialStyle() maps the color scheme into an OrcaPalette via OrcaDefaults.materialPalette() and builds the style through orcaFlatStyle, with density and headingRules options.publish = true), which validates the version, creates the tag on the built commit, and publishes.<img> and <figure>/<figcaption> blocks use the existing image slots and URL policy; inline <img> uses the inline image slot.Orca overload accepts an exact-class inlineOverride map returning AnnotatedString content.taskCheckboxContent allows full replacement.OrcaIncrementalParserSession now freezes a growing prefix of blank-line separated segments (headings, closed code fences, lists, quotes, admonitions, tables, thematic breaks) instead of plain paragraphs only. Only the active tail is re-parsed per update; heading anchor slugs are re-derived so duplicate titles keep full-parse numbering. Verified by prefix-equivalence property tests against the full parser.LAZY_COLUMN — the default lazy root layout is now wrapped in a SelectionContainer, matching the COLUMN mode.OrcaDocument.tableOfContents() in orca-core plus orcaHeadingBlockIndex() in orca-compose for scroll-to-heading UIs on top of LazyListState.streamingCursor glyph on all Orca overloads; the streaming overload shows it only while OrcaStreamingState.isStreaming. Applied to the parsed document, keeping incremental sessions append-only.OrcaAdmonitionStyle (showIcons, per-type icon strings).\textcolor, \color), framed results (\boxed) and stacked annotations (\overset/\underset), plus wasmJs artifacts of the Orcex runtime.++text++ syntax produces OrcaInline.Underline, styled via OrcaInlineStyle.underline.) now renders as a caption below block images; configurable via OrcaImageStyle.showCaption, captionText, and captionSpacing.onTaskToggle callback on all Orca overloads makes - [ ] checkboxes tappable; the host receives the document-order task index and requested state. Rendering stays stateless and dependency-free.<mark>, <kbd>, <u>/<ins>, <sup>, and <sub> now follow OrcaStyle instead of hardcoded light-theme colors, fixing unreadable spans in dark themes. OrcaInlineStyle gains underline and kbd fields.materialStyle() maps the new kbd and image-caption styles to color-scheme tokens.enableEdgeToEdge system-bar styles on toggle, and ships a values-night window background.orca-compose-material3 module with rememberOrcaMaterialStyle() deriving an OrcaStyle from the active MaterialTheme.LazyListState for external scroll control.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.