Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

-------
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
com.mikepenz.java.version=21
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 = [
Expand Down
89 changes: 89 additions & 0 deletions multiplatform-markdown-renderer-latex/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
plugins {
id("com.mikepenz.convention.kotlin-multiplatform")
id("com.mikepenz.convention.compose")
Comment thread
keta1 marked this conversation as resolved.
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")
Comment thread
keta1 marked this conversation as resolved.
uri(url).toURL().openStream().use { input ->
zipFile.outputStream().use { output -> input.copyTo(output) }
}
Comment on lines +21 to +25
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not something we can have in the build script, as that's high risk and could lead to external code to be injected in the project.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, downloading binaries directly in the build script is a security concern.

SPM(Swift Package Manager) itself also downloads the XCFramework from GitHub Releases, but enforces checksum verification.

I see two viable paths forward:

  1. Add SHA-256 checksum verification to the download task (matching what SPM does)
  2. Add RaTeX as a git submodule and build the XCFramework from source during the build process, so the code is fully auditable

Would either of these work for you?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, it's probably best to wait for 2.4.0: https://kotlinlang.org/docs/whatsnew-eap.html#swift-package-import for official SPM support 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great point — Kotlin 2.4.0's SPM import will definitely simplify the iOS story. However, my concern extends beyond just iOS.

If we want to bring full LaTeX rendering to the JVM Desktop target as well (which is currently fallback-only), we'd still need a native binary integrated via JNI. In that scenario, using a git submodule or prebuilt binaries with cinterop/JNI bridging seems like the most practical path — it gives us a single, auditable integration strategy that works across both iOS (via cinterop) and JVM (via JNI), rather than relying on platform-specific package managers for each target.

That said, I'm happy to wait for 2.4.0 if you'd prefer to defer the iOS integration to SPM and tackle JVM support separately. What do you think?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might even be better to build a KMP library around RaTeX, and have this be maintained in isolation. which then publishes a KMP library with all targets properly handled.

And then an individual extension library can be built on top of that to add support for this project.

An interesting side note. In theory plugins/extensions don't have to be in this project directly, but could be maintained outside - as long as the core APIs support adding the functionality (and in this context having a conversation on API stability and extensibility makes sense).

I'd envision this project to be the core, and an ecosystem of extensions being built all around.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — building a standalone KMP library around RaTeX is the right approach. I'm planning to contribute a KMP implementation directly to the RaTeX project, so it can publish proper multiplatform artifacts. Once that's in place, multiplatform-markdown-renderer-latex would just depend on it as a regular Gradle dependency, with no download tasks or cinterop setup needed in this repo.

Hopefully the RaTeX maintainers will be open to the contribution — I'd really prefer not to maintain a separate repo and deal with Maven Central publishing on my own (Sonatype's portal is... not a pleasant experience 😅).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HAha fair on the maven central part. Not sure if you have seen the new central portal: https://central.sonatype.com/ which is definitely better than what it was in the past.

copy {
from(zipTree(zipFile))
into(outDir.parentFile)
}
zipFile.delete()
}
Comment thread
keta1 marked this conversation as resolved.
}
}
Comment thread
keta1 marked this conversation as resolved.

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"
}
Original file line number Diff line number Diff line change
@@ -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()
}
Comment thread
keta1 marked this conversation as resolved.
}

@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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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<DisplayList?>(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,
)
}
}
}
Comment thread
keta1 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Loading