diff --git a/README.md b/README.md index c2b9f24f..945ad62c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - **Customization Options** - Extensive customization for colors, typography, components, and more - **Performance Optimized** - Efficient rendering with lazy loading support for large documents - **Extended Text Spans** - Support for advanced text styling with extended spans +- **LaTeX Math Rendering** - Optional LaTeX math formula rendering for inline and block equations - **Lightweight** - Minimal dependencies and optimized for performance ------- @@ -493,6 +494,28 @@ Markdown( ) ``` +### LaTeX Math Rendering + +The library offers optional support for rendering LaTeX math formulas (both inline `$...$` and +block `$$...$$`) via the `multiplatform-markdown-renderer-latex` module, powered +by [RaTeX](https://github.com/erweixin/RaTeX). + +```groovy +implementation("com.mikepenz:multiplatform-markdown-renderer-latex:${version}") +``` + +Once added, configure the `Markdown` composable with the LaTeX components: + +```kotlin +Markdown( + MARKDOWN, + inlineContent = mathInlineContent(), + components = markdownComponents( + blockMath = latexBlockMath, + ), +) +``` + ## Dependencies This library uses the following key dependencies: @@ -504,6 +527,7 @@ This library uses the following key dependencies: - [Extended Spans](https://github.com/saket/extended-spans) - For advanced text styling (integrated as multiplatform) - [Highlights](https://github.com/SnipMeDev/Highlights) - For code syntax highlighting (optional) +- [RaTeX](https://github.com/erweixin/RaTeX) - For LaTeX math rendering (optional) ## Developed By diff --git a/build.gradle.kts b/build.gradle.kts index c62ad920..47cda021 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,7 @@ plugins { alias(baseLibs.plugins.versionCatalogUpdate) apply false alias(baseLibs.plugins.tapmoc) apply false alias(baseLibs.plugins.paparazzi) apply false + alias(baseLibs.plugins.kotlinSerialization) apply false } allprojects { diff --git a/gradle.properties b/gradle.properties index cadb130c..e27a31d8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,6 +26,7 @@ android.useAndroidX=true # kotlin.mpp.stability.nowarn=true kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.mpp.enableCInteropCommonization=true kotlin.native.ignoreDisabledTargets=true kotlin.native.enableKlibsCrossCompilation=true kotlin.suppressGradlePluginWarnings=IncorrectCompileOnlyDependencyWarning @@ -43,4 +44,4 @@ com.mikepenz.version-catalog-update.enabled=true com.mikepenz.compatPatrouille.enabled=false com.mikepenz.kotlin.version=2.2 com.mikepenz.kotlin.warningsAsErrors.enabled=false -com.mikepenz.java.version=21 \ No newline at end of file +com.mikepenz.java.version=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4ee5d5a..f9487734 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,8 @@ coil2 = "2.7.0" markdown = "0.7.3" ktor = "3.4.2" highlights = "1.1.0" +kotlinx-serialization-json = "1.10.0" +ratex = "0.0.15" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } @@ -19,6 +21,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } highlights = { module = "dev.snipme:highlights", version.ref = "highlights" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +ratex-android = { module = "io.github.erweixin:ratex-android", version.ref = "ratex" } [bundles] coil = [ diff --git a/multiplatform-markdown-renderer-latex/build.gradle.kts b/multiplatform-markdown-renderer-latex/build.gradle.kts new file mode 100644 index 00000000..0c59a20d --- /dev/null +++ b/multiplatform-markdown-renderer-latex/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + id("com.mikepenz.convention.kotlin-multiplatform") + id("com.mikepenz.convention.compose") + id("com.mikepenz.convention.publishing") + alias(baseLibs.plugins.kotlinSerialization) +} + +// Download RaTeX XCFramework from GitHub Releases +val ratexVersion = libs.versions.ratex.get() +val xcframeworkZip = layout.buildDirectory.file("ratex/RaTeX.xcframework.zip") +val xcframeworkDir = layout.buildDirectory.dir("ratex/RaTeX.xcframework") + +val downloadRaTeXXCFramework by tasks.registering { + val zipFile = xcframeworkZip.get().asFile + val outDir = xcframeworkDir.get().asFile + inputs.property("ratexVersion", ratexVersion) + outputs.dir(outDir) + doLast { + if (!outDir.resolve("ios-arm64/libratex_ffi.a").exists()) { + zipFile.parentFile.mkdirs() + val url = "https://github.com/erweixin/RaTeX/releases/download/v$ratexVersion/RaTeX.xcframework.zip" + logger.lifecycle("Downloading RaTeX XCFramework from $url") + uri(url).toURL().openStream().use { input -> + zipFile.outputStream().use { output -> input.copyTo(output) } + } + copy { + from(zipTree(zipFile)) + into(outDir.parentFile) + } + zipFile.delete() + } + } +} + +kotlin { + android { + namespace = "com.mikepenz.markdown.latex" + androidResources.enable = true + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { target -> + target.compilations.getByName("main") { + cinterops { + create("ratex") { + defFile(project.file("src/nativeInterop/cinterop/ratex.def")) + + val xcfDir = xcframeworkDir.get().asFile + val sliceDir = when (target.konanTarget.name) { + "ios_arm64" -> "ios-arm64" + "ios_simulator_arm64", "ios_x64" -> "ios-arm64_x86_64-simulator" + else -> null + } + if (sliceDir != null) { + includeDirs(xcfDir.resolve("$sliceDir/Headers")) + extraOpts("-libraryPath", xcfDir.resolve(sliceDir).absolutePath) + } + } + } + tasks.named(cinterops.getByName("ratex").interopProcessingTaskName) { + dependsOn(downloadRaTeXXCFramework) + } + } + } + + sourceSets { + commonMain.dependencies { + api(projects.multiplatformMarkdownRenderer) + compileOnly(baseLibs.jetbrains.compose.runtime) + compileOnly(baseLibs.jetbrains.compose.ui) + compileOnly(baseLibs.jetbrains.compose.foundation) + implementation(baseLibs.jetbrains.compose.components.resources) + implementation(libs.kotlinx.serialization.json) + } + androidMain.dependencies { + implementation(libs.ratex.android) + } + wasmJsMain.dependencies { + implementation(npm("ratex-wasm", ratexVersion)) + } + } +} + +compose.resources { + packageOfResClass = "com.mikepenz.markdown.latex.generated.resources" +} diff --git a/multiplatform-markdown-renderer-latex/src/androidMain/kotlin/com/mikepenz/markdown/latex/MathEngine.android.kt b/multiplatform-markdown-renderer-latex/src/androidMain/kotlin/com/mikepenz/markdown/latex/MathEngine.android.kt new file mode 100644 index 00000000..84fc8f5a --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/androidMain/kotlin/com/mikepenz/markdown/latex/MathEngine.android.kt @@ -0,0 +1,65 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.ratex.RaTeXEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.mikepenz.markdown.latex.model.DisplayItem as CommonDisplayItem +import com.mikepenz.markdown.latex.model.DisplayList as CommonDisplayList +import com.mikepenz.markdown.latex.model.PathCommand as CommonPathCommand +import com.mikepenz.markdown.latex.model.RaTeXColor as CommonRaTeXColor +import io.ratex.DisplayItem as NativeDisplayItem +import io.ratex.DisplayList as NativeDisplayList +import io.ratex.PathCommand as NativePathCommand +import io.ratex.RaTeXColor as NativeRaTeXColor + +internal actual val isAsyncFontLoading: Boolean = false + +private class AndroidMathEngine : MathEngine { + override suspend fun parse(latex: String): CommonDisplayList { + val native = withContext(Dispatchers.Default) { + RaTeXEngine.parse(latex) + } + return native.toCommon() + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine { + return remember { AndroidMathEngine() } +} + +private fun NativeDisplayList.toCommon() = CommonDisplayList( + width = width, + height = height, + depth = depth, + items = items.map(NativeDisplayItem::toCommon), +) + +private fun NativeDisplayItem.toCommon(): CommonDisplayItem = when (this) { + is NativeDisplayItem.GlyphPath -> CommonDisplayItem.GlyphPath( + x = x, y = y, scale = scale, font = font, charCode = charCode, + commands = commands.map(NativePathCommand::toCommon), color = color.toCommon(), + ) + is NativeDisplayItem.Line -> CommonDisplayItem.Line( + x = x, y = y, width = width, thickness = thickness, color = color.toCommon(), + ) + is NativeDisplayItem.Rect -> CommonDisplayItem.Rect( + x = x, y = y, width = width, height = height, color = color.toCommon(), + ) + is NativeDisplayItem.Path -> CommonDisplayItem.Path( + x = x, y = y, commands = commands.map(NativePathCommand::toCommon), + fill = fill, color = color.toCommon(), + ) +} + +private fun NativePathCommand.toCommon(): CommonPathCommand = when (this) { + is NativePathCommand.MoveTo -> CommonPathCommand.MoveTo(x, y) + is NativePathCommand.LineTo -> CommonPathCommand.LineTo(x, y) + is NativePathCommand.CubicTo -> CommonPathCommand.CubicTo(x1, y1, x2, y2, x, y) + is NativePathCommand.QuadTo -> CommonPathCommand.QuadTo(x1, y1, x, y) + NativePathCommand.Close -> CommonPathCommand.Close +} + +private fun NativeRaTeXColor.toCommon() = CommonRaTeXColor(r, g, b, a) diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_AMS-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_AMS-Regular.ttf new file mode 100644 index 00000000..c6f9a5e7 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_AMS-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Bold.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Bold.ttf new file mode 100644 index 00000000..9ff4a5e0 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Bold.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Regular.ttf new file mode 100644 index 00000000..f522294f Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Caligraphic-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Bold.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Bold.ttf new file mode 100644 index 00000000..4e98259c Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Bold.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Regular.ttf new file mode 100644 index 00000000..b8461b27 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Fraktur-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Bold.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Bold.ttf new file mode 100644 index 00000000..4060e627 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Bold.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-BoldItalic.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-BoldItalic.ttf new file mode 100644 index 00000000..dc007977 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-BoldItalic.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Italic.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Italic.ttf new file mode 100644 index 00000000..0e9b0f35 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Italic.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Regular.ttf new file mode 100644 index 00000000..dd45e1ed Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Main-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-BoldItalic.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-BoldItalic.ttf new file mode 100644 index 00000000..728ce7a1 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-BoldItalic.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-Italic.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-Italic.ttf new file mode 100644 index 00000000..70d559b4 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Math-Italic.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Bold.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Bold.ttf new file mode 100644 index 00000000..2f65a8a3 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Bold.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Italic.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Italic.ttf new file mode 100644 index 00000000..d5850df9 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Italic.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Regular.ttf new file mode 100644 index 00000000..537279f6 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_SansSerif-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Script-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Script-Regular.ttf new file mode 100644 index 00000000..fd679bf3 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Script-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size1-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size1-Regular.ttf new file mode 100644 index 00000000..871fd7d1 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size1-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size2-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size2-Regular.ttf new file mode 100644 index 00000000..7a212caf Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size2-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size3-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size3-Regular.ttf new file mode 100644 index 00000000..00bff349 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size3-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size4-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size4-Regular.ttf new file mode 100644 index 00000000..74f08921 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Size4-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Typewriter-Regular.ttf b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Typewriter-Regular.ttf new file mode 100644 index 00000000..c83252c5 Binary files /dev/null and b/multiplatform-markdown-renderer-latex/src/commonMain/composeResources/font/KaTeX_Typewriter-Regular.ttf differ diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MarkdownBlockMath.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MarkdownBlockMath.kt new file mode 100644 index 00000000..2e6f935d --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MarkdownBlockMath.kt @@ -0,0 +1,66 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.mikepenz.markdown.compose.LocalMarkdownColors +import com.mikepenz.markdown.compose.LocalMarkdownTypography +import com.mikepenz.markdown.compose.components.MarkdownComponent +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.utils.extractCodeFenceContent +import com.mikepenz.markdown.utils.extractMathContent +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode + +val latexBlockMath: MarkdownComponent = { model -> + MarkdownBlockMath(model.content, model.node) +} + +@Composable +fun MarkdownBlockMath(content: String, node: ASTNode) { + val mathEngine = rememberMathEngine() + val color = LocalMarkdownColors.current.text + val typography = LocalMarkdownTypography.current + val fontSize = typography.paragraph.fontSize.value + + val latex = when (node.type) { + MarkdownElementTypes.CODE_FENCE -> node.extractCodeFenceContent(content)?.second ?: "" + else -> node.extractMathContent(content) + } + + if (latex.isBlank()) return + + val displayList by produceState(initialValue = null, key1 = latex) { + value = try { + mathEngine.parse(latex) + } catch (_: Exception) { + null + } + } + + when (val dl = displayList) { + null -> { + BasicText( + text = latex, + style = typography.code.copy(color = color), + ) + } + else -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + MathCanvas( + displayList = dl, + fontSize = fontSize, + color = color, + ) + } + } + } +} diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathCanvas.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathCanvas.kt new file mode 100644 index 00000000..899fb479 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathCanvas.kt @@ -0,0 +1,42 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.sp +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.latex.model.toMathSize +import com.mikepenz.markdown.latex.renderer.DisplayListRenderer +import com.mikepenz.markdown.latex.renderer.rememberKaTeXFontMap + +/** + * Renders a [DisplayList] onto a Compose [Canvas] using the common [DisplayListRenderer]. + */ +@Composable +fun MathCanvas( + displayList: DisplayList, + fontSize: Float, + color: Color, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val fontMap = rememberKaTeXFontMap() + val localDensity = LocalDensity.current + val fontSizePx = with(localDensity) { fontSize.sp.toPx() } + + val mathSize = displayList.toMathSize(fontSize) + val widthDp = with(localDensity) { mathSize.width.sp.toDp() } + val heightDp = with(localDensity) { mathSize.totalHeight.sp.toDp() } + + val renderer = remember(displayList, fontSizePx, color, textMeasurer, fontMap) { + DisplayListRenderer(displayList, fontSizePx, textMeasurer, fontMap, color) + } + Canvas(modifier = modifier.size(widthDp, heightDp)) { + renderer.draw(this) + } +} diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathEngine.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathEngine.kt new file mode 100644 index 00000000..35af74ca --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathEngine.kt @@ -0,0 +1,16 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import com.mikepenz.markdown.latex.model.DisplayList + +internal expect val isAsyncFontLoading: Boolean + +/** + * Platform-specific LaTeX parsing engine. + */ +interface MathEngine { + suspend fun parse(latex: String): DisplayList +} + +@Composable +expect fun rememberMathEngine(): MathEngine diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathInlineContentProvider.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathInlineContentProvider.kt new file mode 100644 index 00000000..a5c1c8a1 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/MathInlineContentProvider.kt @@ -0,0 +1,93 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.unit.sp +import com.mikepenz.markdown.compose.LocalMarkdownColors +import com.mikepenz.markdown.compose.LocalMarkdownTypography +import com.mikepenz.markdown.model.MarkdownInlineContent +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.latex.model.toMathSize +import com.mikepenz.markdown.utils.MARKDOWN_TAG_INLINE_MATH + +/** + * Creates a [MarkdownInlineContent] that renders inline math formulas via [MathEngine]. + * Scans the [AnnotatedString] for `MARKDOWN_TAG_INLINE_MATH` tags, measures each formula, + * and builds the [InlineTextContent] map — all inside a @Composable context where locals are available. + * + * Usage: + * ``` + * Markdown( + * content = markdownText, + * inlineContent = mathInlineContent(), + * components = markdownComponents(blockMath = latexBlockMath), + * ... + * ) + * ``` + */ +fun mathInlineContent(): MarkdownInlineContent = MathInlineContent + +@Immutable +private object MathInlineContent : MarkdownInlineContent { + @Composable + override fun inlineContent(content: AnnotatedString): Map { + val mathEngine = rememberMathEngine() + val color = LocalMarkdownColors.current.text + val fontSize = LocalMarkdownTypography.current.paragraph.fontSize.value + + // Find all inline math annotations + val mathTags = remember(content) { + content.getStringAnnotations(0, content.length) + .filter { it.item.startsWith(MARKDOWN_TAG_INLINE_MATH) } + } + + if (mathTags.isEmpty()) return emptyMap() + + // Parse each formula once + val parsed by produceState(initialValue = emptyMap(), key1 = mathTags) { + val result = mutableMapOf() + for (tag in mathTags) { + val latex = content.subSequence(tag.start, tag.end).text + try { + result[tag.item] = mathEngine.parse(latex) + } catch (_: Exception) { + // skip failed formulas + } + } + value = result + } + + // Build InlineTextContent map — parsed DisplayList used for both sizing and rendering + return remember(mathTags, parsed, color, fontSize) { + buildMap { + for (tag in mathTags) { + val dl = parsed[tag.item] ?: continue + val size = dl.toMathSize(fontSize) + put(tag.item, InlineTextContent( + Placeholder( + width = size.width.sp, + height = size.totalHeight.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ) + ) { _ -> + MathCanvas( + displayList = dl, + fontSize = fontSize, + color = color, + modifier = Modifier.fillMaxSize(), + ) + }) + } + } + } + } +} diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/DisplayList.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/DisplayList.kt new file mode 100644 index 00000000..40758ba3 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/DisplayList.kt @@ -0,0 +1,92 @@ +package com.mikepenz.markdown.latex.model + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Immutable +@Serializable +data class DisplayList( + val width: Double, + val height: Double, + val depth: Double, + val items: List, +) + +@Serializable +sealed class DisplayItem { + @Serializable + @SerialName("GlyphPath") + data class GlyphPath( + val x: Double, + val y: Double, + val scale: Double, + val font: String, + @SerialName("char_code") val charCode: Int, + val commands: List = emptyList(), + val color: RaTeXColor, + ) : DisplayItem() + + @Serializable + @SerialName("Line") + data class Line( + val x: Double, + val y: Double, + val width: Double, + val thickness: Double, + val color: RaTeXColor, + ) : DisplayItem() + + @Serializable + @SerialName("Rect") + data class Rect( + val x: Double, + val y: Double, + val width: Double, + val height: Double, + val color: RaTeXColor, + ) : DisplayItem() + + @Serializable + @SerialName("Path") + data class Path( + val x: Double, + val y: Double, + val commands: List, + val fill: Boolean, + val color: RaTeXColor, + ) : DisplayItem() +} + +@Serializable +sealed class PathCommand { + @Serializable @SerialName("MoveTo") data class MoveTo(val x: Double, val y: Double) : PathCommand() + @Serializable @SerialName("LineTo") data class LineTo(val x: Double, val y: Double) : PathCommand() + @Serializable @SerialName("CubicTo") data class CubicTo( + val x1: Double, val y1: Double, + val x2: Double, val y2: Double, + val x: Double, val y: Double, + ) : PathCommand() + @Serializable @SerialName("QuadTo") data class QuadTo( + val x1: Double, val y1: Double, + val x: Double, val y: Double, + ) : PathCommand() + @Serializable @SerialName("Close") data object Close : PathCommand() +} + +@Serializable +data class RaTeXColor( + val r: Float, + val g: Float, + val b: Float, + val a: Float, +) { + internal fun toComposeColor() = Color(r, g, b, a) +} + +internal val latexJson = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" +} diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/MathSize.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/MathSize.kt new file mode 100644 index 00000000..9139d7aa --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/model/MathSize.kt @@ -0,0 +1,11 @@ +package com.mikepenz.markdown.latex.model + +internal data class MathSize(val width: Float, val height: Float, val depth: Float) { + val totalHeight: Float get() = height + depth +} + +internal fun DisplayList.toMathSize(fontSize: Float) = MathSize( + width = (width * fontSize).toFloat(), + height = (height * fontSize).toFloat(), + depth = (depth * fontSize).toFloat(), +) diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/DisplayListRenderer.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/DisplayListRenderer.kt new file mode 100644 index 00000000..eddbc72d --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/DisplayListRenderer.kt @@ -0,0 +1,143 @@ +package com.mikepenz.markdown.latex.renderer + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.sp +import com.mikepenz.markdown.latex.model.DisplayItem +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.latex.model.PathCommand +import com.mikepenz.markdown.latex.model.RaTeXColor + +/** + * Renders a [DisplayList] onto a Compose [DrawScope]. + * All em-unit coordinates are multiplied by [fontSizePx] to get pixel coordinates. + */ +internal class DisplayListRenderer( + private val displayList: DisplayList, + private val fontSizePx: Float, + private val textMeasurer: TextMeasurer, + private val fontMap: Map, + private val colorOverride: Color = Color.Unspecified, +) { + private val glyphCache = HashMap() + + fun draw(drawScope: DrawScope) { + for (item in displayList.items) { + when (item) { + is DisplayItem.GlyphPath -> drawScope.drawGlyph(item) + is DisplayItem.Line -> drawScope.drawLine(item) + is DisplayItem.Rect -> drawScope.drawRect(item) + is DisplayItem.Path -> drawScope.drawPath(item) + } + } + } + + private fun Double.em() = (this * fontSizePx).toFloat() + + private fun resolveColor(original: RaTeXColor): Color = + colorOverride.takeOrElse { original.toComposeColor() } + + private fun DrawScope.drawGlyph(glyph: DisplayItem.GlyphPath) { + val fontFamily = fontMap[glyph.font] ?: fontMap[glyph.font.removePrefix("KaTeX_")] ?: return + val charCode = glyph.charCode + if (charCode !in 0..0x10FFFF) return + + val str = charCode.toCodePointString() + val color = colorOverride.takeOrElse { glyph.color.toComposeColor() } + val targetPx = fontSizePx * glyph.scale + val fontSizeSp = (targetPx / (density * fontScale)).toFloat() + + val key = GlyphCacheKey(fontFamily, charCode, fontSizeSp, color) + val result = glyphCache.getOrPut(key) { + val style = TextStyle( + fontFamily = fontFamily, + fontSize = fontSizeSp.sp, + color = color, + ) + textMeasurer.measure(str, style) + } + drawText( + textLayoutResult = result, + topLeft = Offset( + (glyph.x * fontSizePx).toFloat(), + (glyph.y * fontSizePx).toFloat() - result.firstBaseline, + ), + ) + } + + private fun DrawScope.drawLine(l: DisplayItem.Line) { + val halfT = (l.thickness * fontSizePx / 2).toFloat() + drawRect( + color = resolveColor(l.color), + topLeft = Offset(l.x.em(), l.y.em() - halfT), + size = Size(l.width.em(), halfT * 2), + ) + } + + private fun DrawScope.drawRect(r: DisplayItem.Rect) { + drawRect( + color = resolveColor(r.color), + topLeft = Offset(r.x.em(), r.y.em()), + size = Size(r.width.em(), r.height.em()), + ) + } + + private fun DrawScope.drawPath(p: DisplayItem.Path) { + val path = buildComposePath(p.commands, p.x.em(), p.y.em()) + drawPath( + path = path, + color = resolveColor(p.color), + style = if (p.fill) Fill else Stroke(), + ) + } + + private fun buildComposePath(commands: List, dx: Float, dy: Float): Path { + val path = Path() + for (cmd in commands) { + when (cmd) { + is PathCommand.MoveTo -> path.moveTo(dx + cmd.x.em(), dy + cmd.y.em()) + is PathCommand.LineTo -> path.lineTo(dx + cmd.x.em(), dy + cmd.y.em()) + is PathCommand.CubicTo -> path.cubicTo( + dx + cmd.x1.em(), dy + cmd.y1.em(), + dx + cmd.x2.em(), dy + cmd.y2.em(), + dx + cmd.x.em(), dy + cmd.y.em(), + ) + is PathCommand.QuadTo -> path.quadraticTo( + dx + cmd.x1.em(), dy + cmd.y1.em(), + dx + cmd.x.em(), dy + cmd.y.em(), + ) + PathCommand.Close -> path.close() + } + } + return path + } +} + +private data class GlyphCacheKey( + val fontFamily: FontFamily, + val charCode: Int, + val fontSizeSp: Float, + val color: Color, +) + +/** Convert a Unicode code point to a String (supports supplementary planes). */ +private fun Int.toCodePointString(): String { + return if (this < 0x10000) { + this.toChar().toString() + } else { + val high = ((this - 0x10000) shr 10) + 0xD800 + val low = ((this - 0x10000) and 0x3FF) + 0xDC00 + charArrayOf(high.toChar(), low.toChar()).concatToString() + } +} diff --git a/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/KaTeXFonts.kt b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/KaTeXFonts.kt new file mode 100644 index 00000000..8514af8a --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/commonMain/kotlin/com/mikepenz/markdown/latex/renderer/KaTeXFonts.kt @@ -0,0 +1,105 @@ +package com.mikepenz.markdown.latex.renderer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.font.FontFamily +import com.mikepenz.markdown.latex.generated.resources.KaTeX_AMS_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Caligraphic_Bold +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Caligraphic_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Fraktur_Bold +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Fraktur_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Main_Bold +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Main_BoldItalic +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Main_Italic +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Main_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Math_BoldItalic +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Math_Italic +import com.mikepenz.markdown.latex.generated.resources.KaTeX_SansSerif_Bold +import com.mikepenz.markdown.latex.generated.resources.KaTeX_SansSerif_Italic +import com.mikepenz.markdown.latex.generated.resources.KaTeX_SansSerif_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Script_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Size1_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Size2_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Size3_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Size4_Regular +import com.mikepenz.markdown.latex.generated.resources.KaTeX_Typewriter_Regular +import com.mikepenz.markdown.latex.generated.resources.Res +import com.mikepenz.markdown.latex.isAsyncFontLoading +import org.jetbrains.compose.resources.Font + +@Composable +fun rememberKaTeXFontMap(): Map { + val amsRegular = FontFamily(Font(Res.font.KaTeX_AMS_Regular)) + val caligraphicBold = FontFamily(Font(Res.font.KaTeX_Caligraphic_Bold)) + val caligraphicRegular = FontFamily(Font(Res.font.KaTeX_Caligraphic_Regular)) + val frakturBold = FontFamily(Font(Res.font.KaTeX_Fraktur_Bold)) + val frakturRegular = FontFamily(Font(Res.font.KaTeX_Fraktur_Regular)) + val mainBold = FontFamily(Font(Res.font.KaTeX_Main_Bold)) + val mainBoldItalic = FontFamily(Font(Res.font.KaTeX_Main_BoldItalic)) + val mainItalic = FontFamily(Font(Res.font.KaTeX_Main_Italic)) + val mainRegular = FontFamily(Font(Res.font.KaTeX_Main_Regular)) + val mathBoldItalic = FontFamily(Font(Res.font.KaTeX_Math_BoldItalic)) + val mathItalic = FontFamily(Font(Res.font.KaTeX_Math_Italic)) + val sansSerifBold = FontFamily(Font(Res.font.KaTeX_SansSerif_Bold)) + val sansSerifItalic = FontFamily(Font(Res.font.KaTeX_SansSerif_Italic)) + val sansSerifRegular = FontFamily(Font(Res.font.KaTeX_SansSerif_Regular)) + val scriptRegular = FontFamily(Font(Res.font.KaTeX_Script_Regular)) + val size1Regular = FontFamily(Font(Res.font.KaTeX_Size1_Regular)) + val size2Regular = FontFamily(Font(Res.font.KaTeX_Size2_Regular)) + val size3Regular = FontFamily(Font(Res.font.KaTeX_Size3_Regular)) + val size4Regular = FontFamily(Font(Res.font.KaTeX_Size4_Regular)) + val typewriterRegular = FontFamily(Font(Res.font.KaTeX_Typewriter_Regular)) + + // On platforms with async font loading (JS/WASM), skip `remember` so that + // font load completion triggers a new map reference, which causes MathCanvas + // to recreate its renderer with the loaded fonts. + return if (isAsyncFontLoading) { + mapOf( + "AMS-Regular" to amsRegular, + "Caligraphic-Bold" to caligraphicBold, + "Caligraphic-Regular" to caligraphicRegular, + "Fraktur-Bold" to frakturBold, + "Fraktur-Regular" to frakturRegular, + "Main-Bold" to mainBold, + "Main-BoldItalic" to mainBoldItalic, + "Main-Italic" to mainItalic, + "Main-Regular" to mainRegular, + "Math-BoldItalic" to mathBoldItalic, + "Math-Italic" to mathItalic, + "SansSerif-Bold" to sansSerifBold, + "SansSerif-Italic" to sansSerifItalic, + "SansSerif-Regular" to sansSerifRegular, + "Script-Regular" to scriptRegular, + "Size1-Regular" to size1Regular, + "Size2-Regular" to size2Regular, + "Size3-Regular" to size3Regular, + "Size4-Regular" to size4Regular, + "Typewriter-Regular" to typewriterRegular, + ) + } else { + remember { + mapOf( + "AMS-Regular" to amsRegular, + "Caligraphic-Bold" to caligraphicBold, + "Caligraphic-Regular" to caligraphicRegular, + "Fraktur-Bold" to frakturBold, + "Fraktur-Regular" to frakturRegular, + "Main-Bold" to mainBold, + "Main-BoldItalic" to mainBoldItalic, + "Main-Italic" to mainItalic, + "Main-Regular" to mainRegular, + "Math-BoldItalic" to mathBoldItalic, + "Math-Italic" to mathItalic, + "SansSerif-Bold" to sansSerifBold, + "SansSerif-Italic" to sansSerifItalic, + "SansSerif-Regular" to sansSerifRegular, + "Script-Regular" to scriptRegular, + "Size1-Regular" to size1Regular, + "Size2-Regular" to size2Regular, + "Size3-Regular" to size3Regular, + "Size4-Regular" to size4Regular, + "Typewriter-Regular" to typewriterRegular, + ) + } + } +} diff --git a/multiplatform-markdown-renderer-latex/src/iosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.ios.kt b/multiplatform-markdown-renderer-latex/src/iosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.ios.kt new file mode 100644 index 00000000..664371cb --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/iosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.ios.kt @@ -0,0 +1,36 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.latex.model.latexJson +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.toKString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import ratex.ffi.ratex_free_display_list +import ratex.ffi.ratex_get_last_error +import ratex.ffi.ratex_parse_and_layout + +internal actual val isAsyncFontLoading: Boolean = false + +private class IosMathEngine : MathEngine { + @OptIn(ExperimentalForeignApi::class) + private suspend fun parseJson(latex: String): String = withContext(Dispatchers.IO) { + val ptr = ratex_parse_and_layout(latex) + ?: throw RuntimeException(ratex_get_last_error()?.toKString() ?: "RaTeX parse error") + try { + ptr.toKString() + } finally { + ratex_free_display_list(ptr) + } + } + + override suspend fun parse(latex: String): DisplayList { + return latexJson.decodeFromString(parseJson(latex)) + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine = remember { IosMathEngine() } diff --git a/multiplatform-markdown-renderer-latex/src/jsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.js.kt b/multiplatform-markdown-renderer-latex/src/jsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.js.kt new file mode 100644 index 00000000..dea6c255 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/jsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.js.kt @@ -0,0 +1,18 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mikepenz.markdown.latex.model.DisplayList + +internal actual val isAsyncFontLoading: Boolean = true + +private class JsMathEngine : MathEngine { + override suspend fun parse(latex: String): DisplayList { + throw UnsupportedOperationException( + "JS LaTeX rendering is not yet supported." + ) + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine = remember { JsMathEngine() } diff --git a/multiplatform-markdown-renderer-latex/src/jvmMain/kotlin/com/mikepenz/markdown/latex/MathEngine.jvm.kt b/multiplatform-markdown-renderer-latex/src/jvmMain/kotlin/com/mikepenz/markdown/latex/MathEngine.jvm.kt new file mode 100644 index 00000000..db0b5da1 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/jvmMain/kotlin/com/mikepenz/markdown/latex/MathEngine.jvm.kt @@ -0,0 +1,18 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mikepenz.markdown.latex.model.DisplayList + +internal actual val isAsyncFontLoading: Boolean = false + +private class JvmMathEngine : MathEngine { + override suspend fun parse(latex: String): DisplayList { + throw UnsupportedOperationException( + "JVM Desktop LaTeX rendering requires a JNI bridge for libratex_ffi." + ) + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine = remember { JvmMathEngine() } diff --git a/multiplatform-markdown-renderer-latex/src/macosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.macos.kt b/multiplatform-markdown-renderer-latex/src/macosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.macos.kt new file mode 100644 index 00000000..0330a4dd --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/macosMain/kotlin/com/mikepenz/markdown/latex/MathEngine.macos.kt @@ -0,0 +1,18 @@ +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mikepenz.markdown.latex.model.DisplayList + +internal actual val isAsyncFontLoading: Boolean = false + +private class MacosMathEngine : MathEngine { + override suspend fun parse(latex: String): DisplayList { + throw UnsupportedOperationException( + "macOS LaTeX rendering is not yet supported." + ) + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine = remember { MacosMathEngine() } diff --git a/multiplatform-markdown-renderer-latex/src/nativeInterop/cinterop/ratex.def b/multiplatform-markdown-renderer-latex/src/nativeInterop/cinterop/ratex.def new file mode 100644 index 00000000..d24fb89b --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/nativeInterop/cinterop/ratex.def @@ -0,0 +1,3 @@ +headers = ratex.h +package = ratex.ffi +staticLibraries = libratex_ffi.a diff --git a/multiplatform-markdown-renderer-latex/src/wasmJsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.wasmJs.kt b/multiplatform-markdown-renderer-latex/src/wasmJsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.wasmJs.kt new file mode 100644 index 00000000..13f69259 --- /dev/null +++ b/multiplatform-markdown-renderer-latex/src/wasmJsMain/kotlin/com/mikepenz/markdown/latex/MathEngine.wasmJs.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package com.mikepenz.markdown.latex + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.mikepenz.markdown.latex.model.DisplayList +import com.mikepenz.markdown.latex.model.latexJson +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.Promise +import kotlinx.coroutines.await + +@JsFun( + """ +async () => { + if (!globalThis.__ratexMod) { + const mod = await import('ratex-wasm'); + await mod.initRatex(); + globalThis.__ratexMod = mod; + } +} +""" +) +private external fun jsInitRatex(): Promise + +@JsFun("(latex) => globalThis.__ratexMod.renderLatex(latex)") +private external fun jsRenderLatex(latex: String): String + +internal actual val isAsyncFontLoading: Boolean = true + +private class WasmJsMathEngine : MathEngine { + private var initialized = false + + override suspend fun parse(latex: String): DisplayList { + if (!initialized) { + jsInitRatex().await() + initialized = true + } + val json = jsRenderLatex(latex) + return latexJson.decodeFromString(json) + } +} + +@Composable +actual fun rememberMathEngine(): MathEngine = remember { WasmJsMathEngine() } diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/annotator/AnnotatedStringKtx.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/annotator/AnnotatedStringKtx.kt index d60f10e8..95b59eb8 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/annotator/AnnotatedStringKtx.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/annotator/AnnotatedStringKtx.kt @@ -16,6 +16,8 @@ import com.mikepenz.markdown.model.MarkdownAnnotator import com.mikepenz.markdown.model.ReferenceLinkHandler import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.utils.MARKDOWN_TAG_IMAGE_URL +import com.mikepenz.markdown.utils.MARKDOWN_TAG_INLINE_MATH +import com.mikepenz.markdown.utils.extractMathContent import com.mikepenz.markdown.utils.findChildOfTypeRecursive import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.innerList @@ -302,6 +304,15 @@ fun AnnotatedString.Builder.buildMarkdownAnnotatedString( append(child.getUnescapedTextInNode(content)) } else appendAutoLink(content, child, annotatorSettings) + GFMElementTypes.INLINE_MATH -> { + val text = child.extractMathContent(content) + if (text.isNotEmpty()) { + // Use startOffset as unique key per formula so each gets its own + // InlineTextContent with correctly sized Placeholder. + appendInlineContent("${MARKDOWN_TAG_INLINE_MATH}:${child.startOffset}", text) + } + } + GFMTokenTypes.DOLLAR -> append('$') MarkdownTokenTypes.SINGLE_QUOTE -> append('\'') diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/ComposeLocal.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/ComposeLocal.kt index babc5d8d..b04002fa 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/ComposeLocal.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/ComposeLocal.kt @@ -8,7 +8,7 @@ import com.mikepenz.markdown.model.BulletHandler import com.mikepenz.markdown.model.DefaultMarkdownAnnotator import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig import com.mikepenz.markdown.model.DefaultMarkdownExtendedSpans -import com.mikepenz.markdown.model.DefaultMarkdownInlineContent +import com.mikepenz.markdown.model.markdownInlineContent import com.mikepenz.markdown.model.ImageTransformer import com.mikepenz.markdown.model.ImageWidth import com.mikepenz.markdown.model.MarkdownAnimations @@ -81,7 +81,7 @@ val LocalImageTransformer = staticCompositionLocalOf { * Local [MarkdownInlineContent] provider */ val LocalMarkdownInlineContent = staticCompositionLocalOf { - return@staticCompositionLocalOf DefaultMarkdownInlineContent(mapOf()) + return@staticCompositionLocalOf markdownInlineContent() } /** diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/MarkdownExtension.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/MarkdownExtension.kt index 1030032f..7f6e999d 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/MarkdownExtension.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/MarkdownExtension.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.mikepenz.markdown.compose.components.MarkdownComponentModel import com.mikepenz.markdown.compose.components.MarkdownComponents +import com.mikepenz.markdown.utils.MATH_FENCE_LANGUAGE import org.intellij.markdown.MarkdownElementTypes.ATX_1 import org.intellij.markdown.MarkdownElementTypes.ATX_2 import org.intellij.markdown.MarkdownElementTypes.ATX_3 @@ -22,10 +23,14 @@ import org.intellij.markdown.MarkdownElementTypes.PARAGRAPH import org.intellij.markdown.MarkdownElementTypes.SETEXT_1 import org.intellij.markdown.MarkdownElementTypes.SETEXT_2 import org.intellij.markdown.MarkdownElementTypes.UNORDERED_LIST +import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes.Companion.EOL import org.intellij.markdown.MarkdownTokenTypes.Companion.HORIZONTAL_RULE import org.intellij.markdown.MarkdownTokenTypes.Companion.TEXT import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.findChildOfType +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMElementTypes.TABLE /** @@ -59,7 +64,15 @@ fun MarkdownElement( when (node.type) { TEXT -> components.text(model) EOL -> components.eol(model) - CODE_FENCE -> components.codeFence(model) + CODE_FENCE -> { + val language = node.findChildOfType(MarkdownTokenTypes.FENCE_LANG) + ?.getTextInNode(content)?.toString() + if (language == MATH_FENCE_LANGUAGE) { + components.blockMath(model) + } else { + components.codeFence(model) + } + } CODE_BLOCK -> components.codeBlock(model) ATX_1 -> components.heading1(model) ATX_2 -> components.heading2(model) @@ -70,12 +83,25 @@ fun MarkdownElement( SETEXT_1 -> components.setextHeading1(model) SETEXT_2 -> components.setextHeading2(model) BLOCK_QUOTE -> components.blockQuote(model) - PARAGRAPH -> components.paragraph(model) + PARAGRAPH -> { + val singleChild = node.children.singleOrNull() + if (singleChild != null && singleChild.type == GFMElementTypes.BLOCK_MATH) { + MarkdownElement( + node = singleChild, + components = components, + content = content, + includeSpacer = false, + ) + } else { + components.paragraph(model) + } + } ORDERED_LIST -> components.orderedList(model) UNORDERED_LIST -> components.unorderedList(model) IMAGE -> components.image(model) HORIZONTAL_RULE -> components.horizontalRule(model) TABLE -> components.table(model) + GFMElementTypes.BLOCK_MATH -> components.blockMath(model) else -> { handled = components.custom?.invoke(node.type, model) != null } diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/components/MarkdownComponents.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/components/MarkdownComponents.kt index 3f4bf83b..f80c9f08 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/components/MarkdownComponents.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/components/MarkdownComponents.kt @@ -66,6 +66,7 @@ fun markdownComponents( horizontalRule: MarkdownComponent = CurrentComponentsBridge.horizontalRule, table: MarkdownComponent = CurrentComponentsBridge.table, checkbox: MarkdownComponent = CurrentComponentsBridge.checkbox, + blockMath: MarkdownComponent = CurrentComponentsBridge.blockMath, custom: CustomMarkdownComponent? = CurrentComponentsBridge.custom, ): MarkdownComponents = DefaultMarkdownComponents( text = text, @@ -89,6 +90,7 @@ fun markdownComponents( horizontalRule = horizontalRule, table = table, checkbox = checkbox, + blockMath = blockMath, custom = custom, ) @@ -118,6 +120,7 @@ interface MarkdownComponents { val horizontalRule: MarkdownComponent val table: MarkdownComponent val checkbox: MarkdownComponent + val blockMath: MarkdownComponent val custom: CustomMarkdownComponent? } @@ -144,6 +147,7 @@ private data class DefaultMarkdownComponents( override val horizontalRule: MarkdownComponent, override val table: MarkdownComponent, override val checkbox: MarkdownComponent, + override val blockMath: MarkdownComponent, override val custom: CustomMarkdownComponent?, ) : MarkdownComponents @@ -212,5 +216,8 @@ object CurrentComponentsBridge { val checkbox: MarkdownComponent = { MarkdownCheckBox(it.content, it.node, style = it.typography.text) } + val blockMath: MarkdownComponent = { + MarkdownCodeFence(it.content, it.node, style = it.typography.code) + } val custom: CustomMarkdownComponent? = null } diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownCode.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownCode.kt index b9efc58d..b944d934 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownCode.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownCode.kt @@ -28,6 +28,7 @@ import com.mikepenz.markdown.compose.LocalMarkdownDimens import com.mikepenz.markdown.compose.LocalMarkdownPadding import com.mikepenz.markdown.compose.LocalMarkdownTypography import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText +import com.mikepenz.markdown.utils.extractCodeFenceContent import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.findChildOfType @@ -70,20 +71,8 @@ fun MarkdownCodeFence( style: TextStyle = LocalMarkdownTypography.current.code, block: @Composable (String, String?, TextStyle) -> Unit = { code, language, style -> MarkdownCode(code = code, language = language, style = style) }, ) { - // CODE_FENCE_START, FENCE_LANG, EOL, {content // CODE_FENCE_CONTENT // x-times}, CODE_FENCE_END - // CODE_FENCE_START, EOL, {content // CODE_FENCE_CONTENT // x-times}, EOL - // CODE_FENCE_START, EOL, {content // CODE_FENCE_CONTENT // x-times} - // CODE_FENCE_START, FENCE_LANG, EOL, {content // CODE_FENCE_CONTENT // x-times} - - val language = node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString() - if (node.children.size >= 3) { - val start = node.children[2].startOffset - val minCodeFenceCount = if (language != null && node.children.size > 3) 3 else 2 - val end = node.children[(node.children.size - 2).coerceAtLeast(minCodeFenceCount)].endOffset - block(content.subSequence(start, end).toString().replaceIndent(), language, style) - } else { - // invalid code block, skipping - } + val (language, code) = node.extractCodeFenceContent(content) ?: return + block(code, language, style) } @Composable diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt index 0a03394b..deba9d09 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt @@ -127,6 +127,8 @@ fun MarkdownText( val containerSize = remember { mutableStateOf(Size.Unspecified) } val imageSizeByLink = remember { mutableStateMapOf() } + val userInlineContent = inlineContent.inlineContent(content) + MarkdownBasicText( text = content, modifier = modifier @@ -140,8 +142,8 @@ fun MarkdownText( }, style = style, inlineContent = imageSizeByLink.toPersistentMap().let { imageSizeByLinkSnapshot -> - remember(node, inlineContent.inlineContent, content, containerSize.value, transformer, inlineImageWidth, imageSizeByLinkSnapshot) { - inlineContent.inlineContent + buildImageInlineContent( + remember(node, userInlineContent, content, containerSize.value, transformer, inlineImageWidth, imageSizeByLinkSnapshot) { + userInlineContent + buildImageInlineContent( content, node, transformer, diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/model/MarkdownInlineContent.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/model/MarkdownInlineContent.kt index f0e8580c..ac87f6cb 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/model/MarkdownInlineContent.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/model/MarkdownInlineContent.kt @@ -3,32 +3,33 @@ package com.mikepenz.markdown.model import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString @Immutable interface MarkdownInlineContent { - /** Represents the map used to store tags and corresponding inline content */ - val inlineContent: Map + @Composable + fun inlineContent(content: AnnotatedString): Map } +/** + * Creates a [MarkdownInlineContent] from a static map (ignores the AnnotatedString). + */ +fun markdownInlineContent( + staticContent: Map = mapOf(), +): MarkdownInlineContent = StaticMarkdownInlineContent(staticContent) + @Immutable -class DefaultMarkdownInlineContent( - override val inlineContent: Map, +private class StaticMarkdownInlineContent( + private val staticContent: Map, ) : MarkdownInlineContent { + @Composable + override fun inlineContent(content: AnnotatedString) = staticContent + override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || this::class != other::class) return false - - other as DefaultMarkdownInlineContent - - return inlineContent == other.inlineContent + if (other !is StaticMarkdownInlineContent) return false + return staticContent == other.staticContent } - override fun hashCode(): Int { - return inlineContent.hashCode() - } + override fun hashCode(): Int = staticContent.hashCode() } - -@Composable -fun markdownInlineContent( - content: Map = mapOf(), -): MarkdownInlineContent = DefaultMarkdownInlineContent(content) diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/utils/Extensions.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/utils/Extensions.kt index 1e1a069e..155539b0 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/utils/Extensions.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/utils/Extensions.kt @@ -19,6 +19,47 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes */ const val MARKDOWN_TAG_IMAGE_URL = "MARKDOWN_IMAGE_URL" +/** + * Tag used to indicate an inline math expression for inline content. Required for rendering. + */ +const val MARKDOWN_TAG_INLINE_MATH = "MARKDOWN_INLINE_MATH" + +/** + * Fence language identifier used to detect math code blocks (```math). + */ +const val MATH_FENCE_LANGUAGE = "math" + +/** + * Extract content from a math AST node (INLINE_MATH or BLOCK_MATH) + * by skipping DOLLAR delimiter children. + */ +fun ASTNode.extractMathContent(content: String): String { + val inner = children.filter { it.type != GFMTokenTypes.DOLLAR } + if (inner.isEmpty()) return "" + return content.substring(inner.first().startOffset, inner.last().endOffset).trim() +} + +/** + * Extracts the content and optional language from a CODE_FENCE node. + * Handles both complete fences (with closing ```) and unclosed fences during live editing. + * + * @return a pair of (language, code), or `null` if the fence has fewer than 3 children. + */ +fun ASTNode.extractCodeFenceContent(content: String): Pair? { + // invalid code block, skipping + if (children.size < 3) return null + // CODE_FENCE_START, FENCE_LANG, EOL, {content // CODE_FENCE_CONTENT // x-times}, CODE_FENCE_END + // CODE_FENCE_START, EOL, {content // CODE_FENCE_CONTENT // x-times}, EOL + // CODE_FENCE_START, EOL, {content // CODE_FENCE_CONTENT // x-times} + // CODE_FENCE_START, FENCE_LANG, EOL, {content // CODE_FENCE_CONTENT // x-times} + val language = findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString() + val start = children[2].startOffset + val minCodeFenceCount = if (language != null && children.size > 3) 3 else 2 + val end = children[(children.size - 2).coerceAtLeast(minCodeFenceCount)].endOffset + val code = content.subSequence(start, end).toString().replaceIndent() + return language to code +} + /** * Find a child node recursive */ diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 4716bd1a..abafbfa0 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -5,9 +5,9 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "com.mikepenz.markdown.sample.shared" - experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + androidResources.enable = true } listOf( @@ -28,6 +28,7 @@ kotlin { api(projects.multiplatformMarkdownRendererM3) api(projects.multiplatformMarkdownRendererCoil3) api(projects.multiplatformMarkdownRendererCode) + api(projects.multiplatformMarkdownRendererLatex) implementation(baseLibs.jetbrains.compose.foundation) implementation(baseLibs.jetbrains.compose.ui) diff --git a/sample/shared/src/commonMain/composeResources/files/sample.md b/sample/shared/src/commonMain/composeResources/files/sample.md index 3d1a6242..d0334ca7 100644 --- a/sample/shared/src/commonMain/composeResources/files/sample.md +++ b/sample/shared/src/commonMain/composeResources/files/sample.md @@ -70,7 +70,29 @@ This is an unordered list with task list items: -------- -# Random +### Inline Math + +The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$, where $a \neq 0$. + +Euler's identity: $e^{i\pi} + 1 = 0$. + +### Block Math + +$$ +\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi} +$$ + +$$ +\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6} +$$ + +### Math Code Fence + +```math +\begin{pmatrix} a & b \\ c & d \end{pmatrix} \cdot \begin{pmatrix} e \\ f \end{pmatrix} = \begin{pmatrix} ae + bf \\ ce + df \end{pmatrix} +``` + +-------- ### Getting Started diff --git a/sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/MarkDownPage.kt b/sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/MarkDownPage.kt index e8c10e9b..9e65131c 100644 --- a/sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/MarkDownPage.kt +++ b/sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/MarkDownPage.kt @@ -25,6 +25,8 @@ import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.elements.MarkdownCheckBox import com.mikepenz.markdown.model.markdownExtendedSpans import com.mikepenz.markdown.model.rememberMarkdownState +import com.mikepenz.markdown.latex.latexBlockMath +import com.mikepenz.markdown.latex.mathInlineContent import com.mikepenz.markdown.sample.shared.resources.Res import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.SyntaxThemes @@ -61,8 +63,10 @@ internal fun MarkDownPage(modifier: Modifier = Modifier) { ) }, checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, + blockMath = latexBlockMath, ), imageTransformer = Coil3ImageTransformerImpl, + inlineContent = mathInlineContent(), extendedSpans = markdownExtendedSpans { val animator = rememberSquigglyUnderlineAnimator() remember { diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ec6f8dc..e448693c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,7 +28,7 @@ dependencyResolutionManagement { versionCatalogs { create("baseLibs") { - from("com.mikepenz:version-catalog:0.14.2") + from("com.mikepenz:version-catalog:0.14.3") } } } @@ -39,6 +39,7 @@ include(":multiplatform-markdown-renderer-m3") include(":multiplatform-markdown-renderer-coil2") include(":multiplatform-markdown-renderer-coil3") include(":multiplatform-markdown-renderer-code") +include(":multiplatform-markdown-renderer-latex") include(":sample:shared") include(":sample:android")