From b9ee08c6c0fee3a7eecdeb04a1b7901b9e836722 Mon Sep 17 00:00:00 2001 From: 0x7fff Date: Sun, 23 Mar 2025 10:42:16 +0300 Subject: [PATCH] Add caching for streaming texts --- .../ext/latex/JLatexMathDrawableCache.java | 143 ++++++++++++ .../markwon/ext/latex/JLatexMathPlugin.java | 219 +++++++++++++++--- 2 files changed, 330 insertions(+), 32 deletions(-) create mode 100644 markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathDrawableCache.java diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathDrawableCache.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathDrawableCache.java new file mode 100644 index 00000000..c3cbd33e --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathDrawableCache.java @@ -0,0 +1,143 @@ +package io.noties.markwon.ext.latex; + +import android.graphics.drawable.Drawable; +import android.util.LruCache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import ru.noties.jlatexmath.JLatexMathDrawable; + +/** + * Cache for JLatexMathDrawable instances to improve performance + * by avoiding redundant rendering of the same LaTeX formulas. + * + */ +public class JLatexMathDrawableCache { + + private static final int DEFAULT_CACHE_SIZE = 32; + + // Singleton instance + private static volatile JLatexMathDrawableCache instance; + + // LRU cache to store rendered LaTeX drawables + private final LruCache cache; + + // Whether to enable the cache + private final boolean enabled; + + private JLatexMathDrawableCache(int maxSize, boolean enabled) { + this.cache = new LruCache<>(maxSize); + this.enabled = enabled; + } + + /** + * Get the singleton instance of the cache + */ + @NonNull + public static JLatexMathDrawableCache getInstance() { + if (instance == null) { + synchronized (JLatexMathDrawableCache.class) { + if (instance == null) { + instance = new JLatexMathDrawableCache(DEFAULT_CACHE_SIZE, true); + } + } + } + return instance; + } + + /** + * Create a new instance with custom configuration + * + * @param maxSize maximum number of entries in the cache + * @param enabled whether the cache is enabled + * @return a new cache instance + */ + @NonNull + public static JLatexMathDrawableCache create(int maxSize, boolean enabled) { + return new JLatexMathDrawableCache(maxSize, enabled); + } + + /** + * Get a drawable from the cache + * + * @param key the LaTeX formula string + * @return the cached drawable or null if not found + */ + /** + * Cache entry containing both the drawable and its configuration + */ + private static class CacheEntry { + final Drawable drawable; + final JLatexMathDrawable.Builder builder; + + CacheEntry(Drawable drawable, JLatexMathDrawable.Builder builder) { + this.drawable = drawable; + this.builder = builder; + } + } + + @Nullable + public Drawable get(@NonNull String key) { + if (!enabled) { + return null; + } + + synchronized (cache) { + final CacheEntry entry = (CacheEntry) cache.get(key); + if (entry != null) { + if (entry.drawable instanceof JLatexMathDrawable && entry.builder != null) { + // Recreate the drawable with the same configuration + return entry.builder.build(); + } + return entry.drawable; + } + return null; + } + } + + /** + * Store a drawable in the cache + * + * @param key the LaTeX formula string + * @param drawable the drawable to cache + */ + public void put(@NonNull String key, @NonNull Drawable drawable) { + put(key, drawable, null); + } + + /** + * Store a drawable in the cache with its builder for recreation + * + * @param key the LaTeX formula string + * @param drawable the drawable to cache + * @param builder the builder used to create the drawable + */ + public void put(@NonNull String key, @NonNull Drawable drawable, @Nullable JLatexMathDrawable.Builder builder) { + if (!enabled) { + return; + } + + synchronized (cache) { + cache.put(key, new CacheEntry(drawable, builder)); + } + } + + /** + * Clear the cache + */ + public void clear() { + synchronized (cache) { + cache.evictAll(); + } + } + + /** + * Get the current size of the cache + */ + public int size() { + synchronized (cache) { + return cache.size(); + } + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java index dd8a606e..0b2c1a78 100644 --- a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java @@ -125,12 +125,19 @@ static class Config { final ExecutorService executorService; + final boolean cacheEnabled; + final int cacheSize; + final boolean syncRendering; + Config(@NonNull Builder builder) { this.theme = builder.theme.build(); this.blocksEnabled = builder.blocksEnabled; this.blocksLegacy = builder.blocksLegacy; this.inlinesEnabled = builder.inlinesEnabled; this.errorHandler = builder.errorHandler; + this.cacheEnabled = builder.cacheEnabled; + this.cacheSize = builder.cacheSize; + this.syncRendering = builder.syncRendering; // @since 4.0.0 ExecutorService executorService = builder.executorService; if (executorService == null) { @@ -285,6 +292,9 @@ public static class Builder { private boolean blocksEnabled = true; private boolean blocksLegacy; private boolean inlinesEnabled; + private boolean cacheEnabled = true; + private int cacheSize = 256; + private boolean syncRendering = false; // @since 4.3.0 private ErrorHandler errorHandler; @@ -347,6 +357,43 @@ public Builder executorService(@NonNull ExecutorService executorService) { return this; } + /** + * Enable or disable the LaTeX drawable cache + * + * @param cacheEnabled whether the cache is enabled + * @return this builder + */ + @NonNull + public Builder cacheEnabled(boolean cacheEnabled) { + this.cacheEnabled = cacheEnabled; + return this; + } + + /** + * Set the maximum size of the LaTeX drawable cache + * + * @param cacheSize maximum number of entries in the cache + * @return this builder + */ + @NonNull + public Builder cacheSize(int cacheSize) { + this.cacheSize = cacheSize; + return this; + } + + /** + * Enable or disable synchronous rendering + * When enabled, LaTeX formulas will be rendered on the main thread + * + * @param syncRendering whether to render synchronously + * @return this builder + */ + @NonNull + public Builder syncRendering(boolean syncRendering) { + this.syncRendering = syncRendering; + return this; + } + @NonNull public Config build() { return new Config(this); @@ -359,16 +406,63 @@ static class JLatextAsyncDrawableLoader extends AsyncDrawableLoader { private final Config config; private final Handler handler = new Handler(Looper.getMainLooper()); private final Map> cache = new HashMap<>(3); + private final JLatexMathDrawableCache drawableCache; JLatextAsyncDrawableLoader(@NonNull Config config) { this.config = config; + this.drawableCache = config.cacheEnabled + ? new JLatexMathDrawableCache(config.cacheSize, true) + : new JLatexMathDrawableCache(0, false); } @Override public void load(@NonNull final AsyncDrawable drawable) { - // this method must be called from main-thread only (thus synchronization can be skipped) + if (!(drawable instanceof JLatextAsyncDrawable)) { + Log.e("JLatexMathPlugin", "Expected JLatextAsyncDrawable but got: " + drawable.getClass().getName()); + return; + } + + final JLatextAsyncDrawable jLatextAsyncDrawable = (JLatextAsyncDrawable) drawable; + final String latex = drawable.getDestination(); + final boolean isBlock = jLatextAsyncDrawable.isBlock(); + + // Create a cache key that distinguishes between block and inline formulas + final String cacheKey = createCacheKey(latex, isBlock); + + // Check the cache first + final Drawable cachedDrawable = drawableCache.get(cacheKey); + if (cachedDrawable != null) { + // Use cached drawable immediately + drawable.setResult(cachedDrawable); + return; + } + + // If synchronous rendering is enabled, render immediately on the main thread + if (config.syncRendering) { + try { + final JLatexMathDrawable result; + final JLatexMathDrawable.Builder builder; + + if (isBlock) { + builder = createBlockDrawableBuilder(latex); + } else { + builder = createInlineDrawableBuilder(latex); + } + result = builder.build(); + + // Cache the result with builder + drawableCache.put(cacheKey, result, builder); + + // Set the result immediately + drawable.setResult(result); + } catch (Throwable t) { + handleRenderingError(drawable, t); + } + return; + } + // check for currently running tasks associated with provided drawable final Future future = cache.get(drawable); @@ -376,51 +470,112 @@ public void load(@NonNull final AsyncDrawable drawable) { // as asyncDrawable is immutable, it won't have destination changed (so there is no need // to cancel any started tasks) if (future == null) { - cache.put(drawable, config.executorService.submit(new Runnable() { @Override public void run() { // @since 4.0.1 wrap in try-catch block and add error logging try { - execute(); - } catch (Throwable t) { - // @since 4.3.0 add error handling - final ErrorHandler errorHandler = config.errorHandler; - if (errorHandler == null) { - // as before - Log.e( - "JLatexMathPlugin", - "Error displaying latex: `" + drawable.getDestination() + "`", - t); + final boolean isBlock = jLatextAsyncDrawable.isBlock(); + final JLatexMathDrawable.Builder builder; + final JLatexMathDrawable result; + + if (isBlock) { + builder = createBlockDrawableBuilder(latex); + result = builder.build(); } else { - // just call `getDestination` without casts and checks - final Drawable errorDrawable = errorHandler.handleError( - drawable.getDestination(), - t - ); - if (errorDrawable != null) { - DrawableUtils.applyIntrinsicBoundsIfEmpty(errorDrawable); - setResult(drawable, errorDrawable); - } + builder = createInlineDrawableBuilder(latex); + result = builder.build(); } + + // Cache the result with builder + drawableCache.put(cacheKey, result, builder); + + setResult(drawable, result); + } catch (Throwable t) { + handleRenderingError(drawable, t); } } + })); + } + } - private void execute() { + @NonNull + private JLatexMathDrawable.Builder createBlockDrawableBuilder(@NonNull String latex) { + final JLatexMathTheme theme = config.theme; - final JLatexMathDrawable jLatexMathDrawable; + final JLatexMathTheme.BackgroundProvider backgroundProvider = theme.blockBackgroundProvider(); + final JLatexMathTheme.Padding padding = theme.blockPadding(); + final int color = theme.blockTextColor(); - final JLatextAsyncDrawable jLatextAsyncDrawable = (JLatextAsyncDrawable) drawable; + final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) + .textSize(theme.blockTextSize()) + .align(theme.blockHorizontalAlignment()); - if (jLatextAsyncDrawable.isBlock()) { - jLatexMathDrawable = createBlockDrawable(jLatextAsyncDrawable); - } else { - jLatexMathDrawable = createInlineDrawable(jLatextAsyncDrawable); - } + if (backgroundProvider != null) { + builder.background(backgroundProvider.provide()); + } - setResult(drawable, jLatexMathDrawable); - } - })); + if (padding != null) { + builder.padding(padding.left, padding.top, padding.right, padding.bottom); + } + + if (color != 0) { + builder.color(color); + } + + return builder; + } + + @NonNull + private JLatexMathDrawable.Builder createInlineDrawableBuilder(@NonNull String latex) { + final JLatexMathTheme theme = config.theme; + + final JLatexMathTheme.BackgroundProvider backgroundProvider = theme.inlineBackgroundProvider(); + final JLatexMathTheme.Padding padding = theme.inlinePadding(); + final int color = theme.inlineTextColor(); + + final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) + .textSize(theme.inlineTextSize()); + + if (backgroundProvider != null) { + builder.background(backgroundProvider.provide()); + } + + if (padding != null) { + builder.padding(padding.left, padding.top, padding.right, padding.bottom); + } + + if (color != 0) { + builder.color(color); + } + + return builder; + } + + @NonNull + private String createCacheKey(@NonNull String latex, boolean isBlock) { + return latex + "#" + (isBlock ? "block" : "inline"); + } + + private void handleRenderingError(@NonNull AsyncDrawable drawable, @NonNull Throwable t) { + // @since 4.3.0 add error handling + final ErrorHandler errorHandler = config.errorHandler; + if (errorHandler == null) { + // as before + Log.e( + "JLatexMathPlugin", + "Error displaying latex: `" + drawable.getDestination() + "`", + t); + } else { + // just call `getDestination` without casts and checks + final Drawable errorDrawable = errorHandler.handleError( + drawable.getDestination(), + t + ); + if (errorDrawable != null) { + DrawableUtils.applyIntrinsicBoundsIfEmpty(errorDrawable); + setResult(drawable, errorDrawable); + } } }