-
-
Notifications
You must be signed in to change notification settings - Fork 70
Add LaTeX module for math rendering #535
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
d77d7cd
74b730f
24f0496
94437d8
7fd53df
0b41a53
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| 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") | ||
|
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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Would either of these work for you?
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤔
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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 😅).
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
|
keta1 marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
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() | ||
| } | ||
|
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) | ||
| 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, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
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 |
Uh oh!
There was an error while loading. Please reload this page.