diff --git a/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png index eda1594f..59b42787 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/code_block_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/inline_math_display.png b/.maestro/enrichedMarkdownText/screenshots/android/inline_math_display.png index 23349cb3..acba0266 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/inline_math_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/inline_math_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png b/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png index deafa22b..e0369541 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/math_display_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png index 792eda8e..14fde75f 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/android/paragraph_math_combo_display.png differ diff --git a/android/build.gradle b/android/build.gradle index f86640c7..9bc6b22a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -47,25 +47,6 @@ android { } } - packaging { - jniLibs { - // Android test APK builds merge native libraries from this module and - // react-android. AndroidMath also ships libc++_shared.so, so keep a - // single copy to avoid duplicate native library failures in Detox and - // other instrumentation builds. Remove this workaround after migrating - // away from AndroidMath if the new math backend does not package it. - pickFirsts += ["**/libc++_shared.so"] - } - - resources { - // AndroidMath pulls in appcompat/lifecycle dependencies that can bring - // both coroutines-core and coroutines-android into androidTest packaging. - // They ship the same ProGuard metadata file, so keep one copy to avoid - // mergeDebugAndroidTestJavaResource failures. Revisit with AndroidMath. - pickFirsts += ["META-INF/com.android.tools/proguard/coroutines.pro"] - } - } - lintOptions { disable "GradleCompatible" } @@ -91,7 +72,6 @@ android { repositories { mavenCentral() google() - maven { url 'https://jitpack.io' } } def kotlin_version = getExtOrDefault("kotlinVersion") @@ -103,9 +83,7 @@ dependencies { implementation "androidx.profileinstaller:profileinstaller:1.3.1" // LaTeX math rendering (optional — set enrichedMarkdown.enableMath=false in gradle.properties to exclude) if (enableMath) { - implementation("com.github.gregcockroft:AndroidMath:v1.1.0") { - exclude group: 'com.google.guava', module: 'guava' - } + implementation("io.github.erweixin:ratex-android:0.1.10") } } diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro index a512f74d..74627894 100644 --- a/android/consumer-rules.pro +++ b/android/consumer-rules.pro @@ -14,7 +14,6 @@ -keep class com.swmansion.enriched.markdown.views.MathContainerView { *; } -keep class com.swmansion.enriched.markdown.renderer.MathInlineRenderer { *; } -# AndroidMath and its FreeType dependency use JNI to instantiate Java objects from native code. +# RaTeX uses JNI to instantiate Java objects from native code. # R8 cannot trace these lookups and will strip or rename the referenced classes. --keep class com.agog.mathdisplay.** { *; } --keep class com.pvporbit.freetype.** { *; } +-keep class io.ratex.** { *; } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt index aabf99e8..76dd0fdf 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt @@ -460,7 +460,8 @@ class EnrichedMarkdown .newInstance(context, style) as View resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex) view - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(TAG, "Failed to create math view", e) View(context) } } diff --git a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt index a6490d12..72eaad12 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt @@ -388,7 +388,7 @@ object MeasurementStore { for (i in mathSegmentIndices.indices) { val metrics = mathResults[i] mathHeightByIndex[mathSegmentIndices[i]] = - (metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2) + ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2) } } @@ -607,7 +607,7 @@ object MeasurementStore { } return try { val mathMeasureHelperClass = Class.forName("com.swmansion.enriched.markdown.spans.MathMeasureHelper") - val method = mathMeasureHelperClass.getMethod("measureOnMainThread", Context::class.java, List::class.java) + val method = mathMeasureHelperClass.getMethod("measure", Context::class.java, List::class.java) @Suppress("UNCHECKED_CAST") method.invoke(null, context, requests) as List } catch (_: Exception) { diff --git a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt index bcf87380..368725d4 100644 --- a/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt +++ b/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt @@ -37,7 +37,7 @@ fun SpannableString.replaceMathSpansWithPlaceholders(context: Context) { } val mathMeasureHelperClass = Class.forName("com.swmansion.enriched.markdown.spans.MathMeasureHelper") - val measureMethod = mathMeasureHelperClass.getMethod("measureOnMainThread", Context::class.java, List::class.java) + val measureMethod = mathMeasureHelperClass.getMethod("measure", Context::class.java, List::class.java) @Suppress("UNCHECKED_CAST") val results = measureMethod.invoke(null, context, requests) as? List ?: return diff --git a/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt b/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt index 7429fd92..3ad86689 100644 --- a/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +++ b/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt @@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.MathInlineSpan import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE +import io.ratex.RaTeXFontLoader class MathInlineRenderer( private val config: RendererConfig, @@ -17,6 +18,8 @@ class MathInlineRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + RaTeXFontLoader.ensureLoaded(context) + val latex = extractLatex(node) if (latex.isEmpty()) return diff --git a/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt b/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt index 62154208..5072d4c3 100644 --- a/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +++ b/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt @@ -5,9 +5,10 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.text.style.ReplacementSpan -import android.view.View.MeasureSpec -import com.agog.mathdisplay.MTMathView -import kotlin.math.roundToInt +import io.ratex.RaTeXEngine +import io.ratex.RaTeXFontLoader +import io.ratex.RaTeXRenderer +import kotlin.math.ceil class MathInlineSpan( private val context: Context, @@ -26,28 +27,21 @@ class MathInlineSpan( if (renderFailed) return try { - val mathView = - MTMathView(context).apply { - labelMode = MTMathView.MTMathViewMode.KMTMathViewModeText - textAlignment = MTMathView.MTTextAlignment.KMTTextAlignmentLeft - this.fontSize = this@MathInlineSpan.fontSize - this.textColor = this@MathInlineSpan.textColor - this.latex = this@MathInlineSpan.latex - } + val displayList = RaTeXEngine.parseBlocking(latex, displayMode = false, color = textColor) + val renderer = RaTeXRenderer(displayList, fontSize) { RaTeXFontLoader.getTypeface(it) } - val spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) - mathView.measure(spec, spec) + cachedWidth = renderer.widthPx.toInt().coerceAtLeast(1) + mathAscent = renderer.heightPx + mathDescent = renderer.depthPx - val width = mathView.measuredWidth.coerceAtLeast(1) - val height = mathView.measuredHeight.coerceAtLeast(1) + val bitmap = + Bitmap.createBitmap( + cachedWidth, + ceil(renderer.totalHeightPx).toInt().coerceAtLeast(1), + Bitmap.Config.ARGB_8888, + ) - cachedWidth = width - calculateMetrics(mathView, height) - - mathView.layout(0, 0, width, height) - - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - mathView.draw(Canvas(bitmap)) + renderer.draw(Canvas(bitmap)) cachedBitmap = bitmap } catch (_: Exception) { renderFailed = true @@ -58,25 +52,6 @@ class MathInlineSpan( } } - private fun calculateMetrics( - view: MTMathView, - height: Int, - ) { - try { - val dl = DISPLAY_LIST_FIELD?.get(view) - if (dl != null) { - mathAscent = GET_ASCENT_METHOD?.invoke(dl) as? Float ?: (height * 0.7f) - mathDescent = GET_DESCENT_METHOD?.invoke(dl) as? Float ?: (height * 0.3f) - } else { - mathAscent = height * 0.7f - mathDescent = height * 0.3f - } - } catch (e: Exception) { - mathAscent = height * 0.7f - mathDescent = height * 0.3f - } - } - override fun getSize( paint: Paint, text: CharSequence?, @@ -87,9 +62,10 @@ class MathInlineSpan( prepareResources() fm?.apply { - ascent = -mathAscent.roundToInt() + val ascentPx = ceil(mathAscent).toInt() + ascent = -ascentPx top = ascent - descent = mathDescent.roundToInt() + descent = (cachedBitmap?.height ?: ceil(mathAscent + mathDescent).toInt()) - ascentPx bottom = descent } @@ -109,25 +85,8 @@ class MathInlineSpan( ) { prepareResources() cachedBitmap?.let { - val bitmapY = y - mathAscent + val bitmapY = y - ceil(mathAscent) canvas.drawBitmap(it, x, bitmapY, paint) } } - - companion object { - private val DISPLAY_LIST_FIELD = - runCatching { - MTMathView::class.java.getDeclaredField("displayList").apply { isAccessible = true } - }.getOrNull() - - private val GET_ASCENT_METHOD = - runCatching { - DISPLAY_LIST_FIELD?.type?.getMethod("getAscent") - }.getOrNull() - - private val GET_DESCENT_METHOD = - runCatching { - DISPLAY_LIST_FIELD?.type?.getMethod("getDescent") - }.getOrNull() - } } diff --git a/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt b/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt index e5a3dd00..d0eda85e 100644 --- a/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt +++ b/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt @@ -1,49 +1,23 @@ package com.swmansion.enriched.markdown.spans import android.content.Context -import android.os.Handler -import android.os.Looper -import android.view.View.MeasureSpec -import com.agog.mathdisplay.MTMathView -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit +import android.util.Log +import io.ratex.RaTeXEngine +import io.ratex.RaTeXFontLoader +import io.ratex.RaTeXRenderer object MathMeasureHelper { - private const val BASE_TIMEOUT_MS = 500L - private const val PER_ITEM_TIMEOUT_MS = 50L - private val mainHandler = Handler(Looper.getMainLooper()) - private var sharedMathView: MTMathView? = null - @JvmStatic - fun measureOnMainThread( + fun measure( context: Context, requests: List, ): List { if (requests.isEmpty()) return emptyList() - if (Looper.myLooper() == Looper.getMainLooper()) { - return requests.map { measureSingle(context, it) } - } - - val results = mutableListOf() - val latch = CountDownLatch(1) - val timeout = BASE_TIMEOUT_MS + (PER_ITEM_TIMEOUT_MS * requests.size) - - mainHandler.post { - requests.mapTo(results) { request -> - runCatching { measureSingle(context, request) }.getOrNull() - } - latch.countDown() - } - - val completed = latch.await(timeout, TimeUnit.MILLISECONDS) + RaTeXFontLoader.ensureLoaded(context) - return requests.mapIndexed { i, req -> - if (completed) { - results.getOrNull(i) ?: estimateFallback(req) - } else { - estimateFallback(req) - } + return requests.map { request -> + runCatching { measureSingle(context, request) }.getOrElse { estimateFallback(request) } } } @@ -51,34 +25,22 @@ object MathMeasureHelper { context: Context, request: MathMeasureRequest, ): MathMetrics { - val mathView = - ( - sharedMathView ?: MTMathView(context.applicationContext).also { - sharedMathView = it - } - ).apply { - labelMode = - when (request.mode) { - MathRenderMode.Display -> MTMathView.MTMathViewMode.KMTMathViewModeDisplay - else -> MTMathView.MTMathViewMode.KMTMathViewModeText - } - textAlignment = MTMathView.MTTextAlignment.KMTTextAlignmentLeft - fontSize = request.fontSize - latex = request.latex + val displayList = + RaTeXEngine.parseBlocking( + request.latex, + displayMode = request.mode == MathRenderMode.Display, + ) + + val renderer = + RaTeXRenderer(displayList, request.fontSize) { + RaTeXFontLoader.getTypeface(it) } - val spec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) - mathView.measure(spec, spec) - - val width = mathView.measuredWidth.coerceAtLeast(1) - val height = mathView.measuredHeight.coerceAtLeast(1).toFloat() - - return runCatching { - val dl = displayListField?.get(mathView) ?: throw Exception() - val ascent = getAscentMethod?.invoke(dl) as Float - val descent = getDescentMethod?.invoke(dl) as Float - MathMetrics(width, ascent, descent) - }.getOrDefault(MathMetrics(width, height * 0.7f, height * 0.3f)) + return MathMetrics( + renderer.widthPx.toInt(), + renderer.heightPx, + renderer.depthPx, + ) } private fun estimateFallback(request: MathMeasureRequest): MathMetrics { @@ -92,12 +54,4 @@ object MathMeasureHelper { descent = h * 0.3f, ) } - - private val displayListField = - runCatching { - MTMathView::class.java.getDeclaredField("displayList").apply { isAccessible = true } - }.getOrNull() - - private val getAscentMethod = runCatching { displayListField?.type?.getMethod("getAscent") }.getOrNull() - private val getDescentMethod = runCatching { displayListField?.type?.getMethod("getDescent") }.getOrNull() } diff --git a/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt b/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt index 668041f1..6383c480 100644 --- a/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +++ b/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt @@ -3,18 +3,23 @@ package com.swmansion.enriched.markdown.views import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.graphics.Canvas +import android.util.Log import android.view.Gravity import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.FrameLayout import android.widget.HorizontalScrollView -import com.agog.mathdisplay.MTMathView import com.swmansion.enriched.markdown.spans.MathMeasureHelper import com.swmansion.enriched.markdown.spans.MathMeasureRequest import com.swmansion.enriched.markdown.spans.MathRenderMode import com.swmansion.enriched.markdown.styles.MathStyle import com.swmansion.enriched.markdown.styles.StyleConfig +import io.ratex.RaTeXEngine +import io.ratex.RaTeXFontLoader +import io.ratex.RaTeXRenderer +import kotlin.math.ceil class MathContainerView( context: Context, @@ -22,35 +27,31 @@ class MathContainerView( ) : FrameLayout(context), BlockSegmentView { private val mathStyle: MathStyle = styleConfig.mathStyle - private val mathView = MTMathView(context) private val scrollView = HorizontalScrollView(context) private var cachedLatex: String = "" override val segmentMarginTop: Int get() = mathStyle.marginTop.toInt() override val segmentMarginBottom: Int get() = mathStyle.marginBottom.toInt() - private val alignmentPair = + private val mathGravity = when (mathStyle.textAlign) { - "left" -> MTMathView.MTTextAlignment.KMTTextAlignmentLeft to Gravity.START - "right" -> MTMathView.MTTextAlignment.KMTTextAlignmentRight to Gravity.END - else -> MTMathView.MTTextAlignment.KMTTextAlignmentCenter to Gravity.CENTER_HORIZONTAL + "left" -> Gravity.START + "right" -> Gravity.END + else -> Gravity.CENTER_HORIZONTAL } + private val mathView = RaTeXCanvasView(context) + init { setBackgroundColor(mathStyle.backgroundColor) val paddingPx = mathStyle.padding.toInt() - mathView.apply { - labelMode = MTMathView.MTMathViewMode.KMTMathViewModeDisplay - fontSize = mathStyle.fontSize - textColor = mathStyle.color - textAlignment = alignmentPair.first - } + RaTeXFontLoader.ensureLoaded(context) val mathLayoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { - gravity = alignmentPair.second + gravity = mathGravity } val mathWrapper = @@ -80,7 +81,15 @@ class MathContainerView( fun applyLatex(latex: String) { cachedLatex = latex - mathView.latex = latex + try { + val displayList = RaTeXEngine.parseBlocking(latex, displayMode = true, color = mathStyle.color) + mathView.renderer = RaTeXRenderer(displayList, mathStyle.fontSize) { RaTeXFontLoader.getTypeface(it) } + } catch (e: Exception) { + Log.e(TAG, "Failed to render LaTeX", e) + mathView.renderer = null + } + mathView.requestLayout() + mathView.invalidate() } private fun showContextMenu(anchor: View) { @@ -95,6 +104,29 @@ class MathContainerView( } } + private class RaTeXCanvasView( + context: Context, + ) : View(context) { + var renderer: RaTeXRenderer? = null + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val currentRenderer = + renderer + ?: return setMeasuredDimension(0, 0) + setMeasuredDimension( + ceil(currentRenderer.widthPx).toInt().coerceAtLeast(1), + ceil(currentRenderer.totalHeightPx).toInt().coerceAtLeast(1), + ) + } + + override fun onDraw(canvas: Canvas) { + renderer?.draw(canvas) + } + } + companion object { fun measureMathHeight( latex: String, @@ -107,8 +139,10 @@ class MathContainerView( latex = latex, mode = MathRenderMode.Display, ) - val metrics = MathMeasureHelper.measureOnMainThread(context, listOf(request)).first() - return (metrics.ascent + metrics.descent).toInt() + (mathStyle.padding * 2) + val metrics = MathMeasureHelper.measure(context, listOf(request)).first() + return ceil(metrics.ascent + metrics.descent).toInt() + (mathStyle.padding * 2) } + + private const val TAG = "MathContainerView" } } diff --git a/apps/example/android/gradle.properties b/apps/example/android/gradle.properties index 6e6384bd..c224c4fc 100644 --- a/apps/example/android/gradle.properties +++ b/apps/example/android/gradle.properties @@ -43,5 +43,5 @@ hermesEnabled=true # Note: Only works with ReactActivity and should not be used with custom Activity. edgeToEdgeEnabled=false -# Set to false to disable LaTeX math rendering and remove the AndroidMath dependency. +# Set to false to disable LaTeX math rendering and remove the RaTeX dependency. #enrichedMarkdown.enableMath=false