From e35d3ad04436da6924a595f256790061c27baeac Mon Sep 17 00:00:00 2001 From: Dimitry Ivanov Date: Wed, 29 May 2019 13:41:40 +0300 Subject: [PATCH] Update jlatex plugin to be independent of images --- _CHANGES.md | 6 +- .../markwon/image/AsyncDrawableScheduler.java | 97 +++++---- markwon-core/src/main/res/values/ids.xml | 1 + .../markwon/AbstractMarkwonPluginTest.java | 2 +- markwon-ext-latex/build.gradle | 1 + .../markwon/ext/latex/JLatexMathPlugin.java | 202 ++++++++++++------ sample/build.gradle | 1 + .../basicplugins/BasicPluginsActivity.java | 41 ++-- .../CustomExtensionActivity.java | 2 +- .../sample/customextension/IconPlugin.java | 12 +- .../markwon/sample/latex/LatexActivity.java | 2 +- .../sample/recycler/RecyclerActivity.java | 15 +- 12 files changed, 230 insertions(+), 152 deletions(-) diff --git a/_CHANGES.md b/_CHANGES.md index b1bb22c8..3a63fbbe 100644 --- a/_CHANGES.md +++ b/_CHANGES.md @@ -1,2 +1,6 @@ * `Markwon.builder` won't require CorePlugin registration (it is done automatically) - to create a builder without CorePlugin - use `Markwon#builderNoCore` \ No newline at end of file + to create a builder without CorePlugin - use `Markwon#builderNoCore` +* `JLatex` plugin now is not dependent on ImagesPlugin + also accepts a ExecutorService (optional, by default cachedThreadPool is used) +* AsyncDrawableScheduler now can be called by multiple plugins without penalty + internally caches latest state and skips scheduling if drawables are already processed \ No newline at end of file diff --git a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableScheduler.java b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableScheduler.java index 0093abed..dd1735cf 100644 --- a/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableScheduler.java +++ b/markwon-core/src/main/java/ru/noties/markwon/image/AsyncDrawableScheduler.java @@ -5,23 +5,37 @@ import android.graphics.drawable.Drawable; import android.os.Looper; import android.os.SystemClock; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.text.Spanned; -import android.text.style.DynamicDrawableSpan; import android.view.View; import android.widget.TextView; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - import ru.noties.markwon.renderer.R; public abstract class AsyncDrawableScheduler { public static void schedule(@NonNull final TextView textView) { - final List list = extract(textView); - if (list.size() > 0) { + // we need a simple check if current text has already scheduled drawables + // we need this in order to allow multiple calls to schedule (different plugins + // might use AsyncDrawable), but we do not want to repeat the task + // + // hm... we need the same thing for unschedule then... we can check if last hash is !null, + // if it's not -> unschedule, else ignore + + final Integer lastTextHashCode = + (Integer) textView.getTag(R.id.markwon_drawables_scheduler_last_text_hashcode); + final int textHashCode = textView.getText().hashCode(); + if (lastTextHashCode != null + && lastTextHashCode == textHashCode) { + return; + } + textView.setTag(R.id.markwon_drawables_scheduler_last_text_hashcode, textHashCode); + + + final AsyncDrawableSpan[] spans = extractSpans(textView); + if (spans != null + && spans.length > 0) { if (textView.getTag(R.id.markwon_drawables_scheduler) == null) { final View.OnAttachStateChangeListener listener = new View.OnAttachStateChangeListener() { @@ -41,7 +55,10 @@ public abstract class AsyncDrawableScheduler { textView.setTag(R.id.markwon_drawables_scheduler, listener); } - for (AsyncDrawable drawable : list) { + AsyncDrawable drawable; + + for (AsyncDrawableSpan span : spans) { + drawable = span.getDrawable(); drawable.setCallback2(new DrawableCallbackImpl(textView, drawable.getBounds())); } } @@ -49,57 +66,39 @@ public abstract class AsyncDrawableScheduler { // must be called when text manually changed in TextView public static void unschedule(@NonNull TextView view) { - for (AsyncDrawable drawable : extract(view)) { - drawable.setCallback2(null); + + if (view.getTag(R.id.markwon_drawables_scheduler_last_text_hashcode) == null) { + return; + } + view.setTag(R.id.markwon_drawables_scheduler_last_text_hashcode, null); + + + final AsyncDrawableSpan[] spans = extractSpans(view); + if (spans != null + && spans.length > 0) { + for (AsyncDrawableSpan span : spans) { + span.getDrawable().setCallback2(null); + } } } - private static List extract(@NonNull TextView view) { + @Nullable + private static AsyncDrawableSpan[] extractSpans(@NonNull TextView textView) { - final List list; - - final CharSequence cs = view.getText(); + final CharSequence cs = textView.getText(); final int length = cs != null ? cs.length() : 0; - if (length == 0 || !(cs instanceof Spanned)) { - //noinspection unchecked - list = Collections.EMPTY_LIST; - } else { - - final List drawables = new ArrayList<>(2); - - final Spanned spanned = (Spanned) cs; - final AsyncDrawableSpan[] asyncDrawableSpans = spanned.getSpans(0, length, AsyncDrawableSpan.class); - if (asyncDrawableSpans != null - && asyncDrawableSpans.length > 0) { - for (AsyncDrawableSpan span : asyncDrawableSpans) { - drawables.add(span.getDrawable()); - } - } - - final DynamicDrawableSpan[] dynamicDrawableSpans = spanned.getSpans(0, length, DynamicDrawableSpan.class); - if (dynamicDrawableSpans != null - && dynamicDrawableSpans.length > 0) { - for (DynamicDrawableSpan span : dynamicDrawableSpans) { - final Drawable d = span.getDrawable(); - if (d != null - && d instanceof AsyncDrawable) { - drawables.add((AsyncDrawable) d); - } - } - } - - if (drawables.size() == 0) { - //noinspection unchecked - list = Collections.EMPTY_LIST; - } else { - list = drawables; - } + if (length == 0 + || !(cs instanceof Spanned)) { + return null; } - return list; + // we also could've tried the `nextSpanTransition`, but strangely it leads to worse performance + // then direct getSpans + + return ((Spanned) cs).getSpans(0, length, AsyncDrawableSpan.class); } private AsyncDrawableScheduler() { diff --git a/markwon-core/src/main/res/values/ids.xml b/markwon-core/src/main/res/values/ids.xml index 90fb8322..31a4445d 100644 --- a/markwon-core/src/main/res/values/ids.xml +++ b/markwon-core/src/main/res/values/ids.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/markwon-core/src/test/java/ru/noties/markwon/AbstractMarkwonPluginTest.java b/markwon-core/src/test/java/ru/noties/markwon/AbstractMarkwonPluginTest.java index 7ee70ceb..025af005 100644 --- a/markwon-core/src/test/java/ru/noties/markwon/AbstractMarkwonPluginTest.java +++ b/markwon-core/src/test/java/ru/noties/markwon/AbstractMarkwonPluginTest.java @@ -10,7 +10,7 @@ import static org.junit.Assert.assertEquals; @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) public class AbstractMarkwonPluginTest { - + @Test public void process_markdown() { // returns supplied argument (no-op) diff --git a/markwon-ext-latex/build.gradle b/markwon-ext-latex/build.gradle index 7cd28127..da8a9971 100644 --- a/markwon-ext-latex/build.gradle +++ b/markwon-ext-latex/build.gradle @@ -16,6 +16,7 @@ android { dependencies { api project(':markwon-core') + api project(':markwon-image') api deps['jlatexmath-android'] } diff --git a/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java b/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java index 9a09679d..ee28e04e 100644 --- a/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java +++ b/markwon-ext-latex/src/main/java/ru/noties/markwon/ext/latex/JLatexMathPlugin.java @@ -1,24 +1,31 @@ package ru.noties.markwon.ext.latex; import android.graphics.drawable.Drawable; -import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.Px; +import android.text.Spanned; +import android.widget.TextView; -import org.commonmark.node.Image; import org.commonmark.parser.Parser; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; -import java.util.Scanner; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import ru.noties.jlatexmath.JLatexMathDrawable; import ru.noties.markwon.AbstractMarkwonPlugin; +import ru.noties.markwon.MarkwonConfiguration; import ru.noties.markwon.MarkwonVisitor; -import ru.noties.markwon.RenderProps; +import ru.noties.markwon.image.AsyncDrawable; import ru.noties.markwon.image.AsyncDrawableLoader; +import ru.noties.markwon.image.AsyncDrawableScheduler; +import ru.noties.markwon.image.AsyncDrawableSpan; import ru.noties.markwon.image.ImageSize; /** @@ -65,27 +72,29 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { private final int padding; + // @since 4.0.0-SNAPSHOT + private final ExecutorService executorService; + Config(@NonNull Builder builder) { this.textSize = builder.textSize; this.background = builder.background; this.align = builder.align; this.fitCanvas = builder.fitCanvas; this.padding = builder.padding; + + // @since 4.0.0-SNAPSHOT + ExecutorService executorService = builder.executorService; + if (executorService == null) { + executorService = Executors.newCachedThreadPool(); + } + this.executorService = executorService; } } - @NonNull - public static String makeDestination(@NonNull String latex) { - return SCHEME + "://" + latex; - } - - private static final String SCHEME = "jlatexmath"; - private static final String CONTENT_TYPE = "text/jlatexmath"; - - private final Config config; + private final JLatextAsyncDrawableLoader jLatextAsyncDrawableLoader; JLatexMathPlugin(@NonNull Config config) { - this.config = config; + this.jLatextAsyncDrawableLoader = new JLatextAsyncDrawableLoader(config); } @Override @@ -102,70 +111,36 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { final String latex = jLatexMathBlock.latex(); final int length = visitor.length(); + visitor.builder().append(latex); - final RenderProps renderProps = visitor.renderProps(); + final MarkwonConfiguration configuration = visitor.configuration(); - ImageProps.DESTINATION.set(renderProps, makeDestination(latex)); - ImageProps.REPLACEMENT_TEXT_IS_LINK.set(renderProps, false); - ImageProps.IMAGE_SIZE.set(renderProps, new ImageSize(new ImageSize.Dimension(100, "%"), null)); + final AsyncDrawableSpan span = new AsyncDrawableSpan( + configuration.theme(), + new AsyncDrawable( + latex, + jLatextAsyncDrawableLoader, + configuration.imageSizeResolver(), + new ImageSize( + new ImageSize.Dimension(100, "%"), + null)), + AsyncDrawableSpan.ALIGN_BOTTOM, + false); - visitor.setSpansForNode(Image.class, length); + visitor.setSpans(length, span); } }); } @Override - public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { - builder - .addSchemeHandler(SCHEME, new SchemeHandler() { - @Nullable - @Override - public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { - - ImageItem item = null; - - try { - final byte[] bytes = raw.substring(SCHEME.length()).getBytes("UTF-8"); - item = new ImageItem( - CONTENT_TYPE, - new ByteArrayInputStream(bytes)); - } catch (UnsupportedEncodingException e) { - e.printStackTrace(); - } - - return item; - } - }) - .addMediaDecoder(CONTENT_TYPE, new MediaDecoder() { - @Nullable - @Override - public Drawable decode(@NonNull InputStream inputStream) { - - final Scanner scanner = new Scanner(inputStream, "UTF-8").useDelimiter("\\A"); - final String latex = scanner.hasNext() - ? scanner.next() - : null; - - if (latex == null) { - return null; - } - - return JLatexMathDrawable.builder(latex) - .textSize(config.textSize) - .background(config.background) - .align(config.align) - .fitCanvas(config.fitCanvas) - .padding(config.padding) - .build(); - } - }); + public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { + AsyncDrawableScheduler.unschedule(textView); } - @NonNull @Override - public Priority priority() { - return Priority.after(ImagesPlugin.class); + public void afterSetText(@NonNull TextView textView) { + AsyncDrawableScheduler.schedule(textView); } public static class Builder { @@ -181,6 +156,9 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { private int padding; + // @since 4.0.0-SNAPSHOT + private ExecutorService executorService; + Builder(float textSize) { this.textSize = textSize; } @@ -209,9 +187,95 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { return this; } + /** + * @since 4.0.0-SNAPSHOT + */ + @NonNull + public Builder executorService(@NonNull ExecutorService executorService) { + this.executorService = executorService; + return this; + } + @NonNull public Config build() { return new Config(this); } } + + // @since 4.0.0-SNAPSHOT + private static class JLatextAsyncDrawableLoader extends AsyncDrawableLoader { + + private final Config config; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Map> cache = new HashMap<>(3); + + JLatextAsyncDrawableLoader(@NonNull Config config) { + this.config = config; + } + + @Override + public void load(@NonNull final AsyncDrawable drawable) { + + // this method must be called from main-thread only (thus synchronization can be skipped) + + // check for currently running tasks associated with provided drawable + final Future future = cache.get(drawable); + + // if it's present -> proceed with new execution + // 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() { + + // create JLatexMathDrawable + final JLatexMathDrawable jLatexMathDrawable = + JLatexMathDrawable.builder(drawable.getDestination()) + .textSize(config.textSize) + .background(config.background) + .align(config.align) + .fitCanvas(config.fitCanvas) + .padding(config.padding) + .build(); + + // we must post to handler, but also have a way to identify the drawable + // for which we are posting (in case of cancellation) + handler.postAtTime(new Runnable() { + @Override + public void run() { + // remove entry from cache (it will be present if task is not cancelled) + if (cache.remove(drawable) != null + && drawable.isAttached()) { + drawable.setResult(jLatexMathDrawable); + } + + } + }, drawable, SystemClock.uptimeMillis()); + } + })); + } + } + + @Override + public void cancel(@NonNull AsyncDrawable drawable) { + + // this method also must be called from main thread only + + final Future future = cache.remove(drawable); + if (future != null) { + future.cancel(true); + } + + // remove all callbacks (via runnable) and messages posted for this drawable + handler.removeCallbacksAndMessages(drawable); + } + + @Nullable + @Override + public Drawable placeholder(@NonNull AsyncDrawable drawable) { + return null; + } + } } diff --git a/sample/build.gradle b/sample/build.gradle index 649e3a8e..a16436b9 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation it['prism4j'] implementation it['debug'] implementation it['adapt'] + implementation it['android-svg'] } deps['annotationProcessor'].with { diff --git a/sample/src/main/java/ru/noties/markwon/sample/basicplugins/BasicPluginsActivity.java b/sample/src/main/java/ru/noties/markwon/sample/basicplugins/BasicPluginsActivity.java index 45f35cf8..98596972 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/basicplugins/BasicPluginsActivity.java +++ b/sample/src/main/java/ru/noties/markwon/sample/basicplugins/BasicPluginsActivity.java @@ -19,9 +19,8 @@ import ru.noties.markwon.MarkwonConfiguration; import ru.noties.markwon.MarkwonPlugin; import ru.noties.markwon.MarkwonSpansFactory; import ru.noties.markwon.MarkwonVisitor; -import ru.noties.markwon.movement.MovementMethodPlugin; import ru.noties.markwon.core.MarkwonTheme; -import ru.noties.markwon.image.AsyncDrawableLoader; +import ru.noties.markwon.movement.MovementMethodPlugin; public class BasicPluginsActivity extends Activity { @@ -168,25 +167,25 @@ public class BasicPluginsActivity extends Activity { final String markdown = "![image](myownscheme://en.wikipedia.org/static/images/project-logos/enwiki-2x.png)"; final Markwon markwon = Markwon.builder(this) - .usePlugin(ImagesPlugin.create(this)) - .usePlugin(new AbstractMarkwonPlugin() { - @Override - public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { - // we can have a custom SchemeHandler - // here we will just use networkSchemeHandler to redirect call - builder.addSchemeHandler("myownscheme", new SchemeHandler() { - - final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create(); - - @Nullable - @Override - public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { - raw = raw.replace("myownscheme", "https"); - return networkSchemeHandler.handle(raw, Uri.parse(raw)); - } - }); - } - }) +// .usePlugin(ImagesPlugin.create(this)) +// .usePlugin(new AbstractMarkwonPlugin() { +// @Override +// public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { +// // we can have a custom SchemeHandler +// // here we will just use networkSchemeHandler to redirect call +// builder.addSchemeHandler("myownscheme", new SchemeHandler() { +// +// final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create(); +// +// @Nullable +// @Override +// public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { +// raw = raw.replace("myownscheme", "https"); +// return networkSchemeHandler.handle(raw, Uri.parse(raw)); +// } +// }); +// } +// }) .build(); markwon.setMarkdown(textView, markdown); diff --git a/sample/src/main/java/ru/noties/markwon/sample/customextension/CustomExtensionActivity.java b/sample/src/main/java/ru/noties/markwon/sample/customextension/CustomExtensionActivity.java index d2e80e18..71e592c6 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/customextension/CustomExtensionActivity.java +++ b/sample/src/main/java/ru/noties/markwon/sample/customextension/CustomExtensionActivity.java @@ -26,7 +26,7 @@ public class CustomExtensionActivity extends Activity { // `usePlugin` call final Markwon markwon = Markwon.builder(this) // try commenting out this line to see runtime dependency resolution - .usePlugin(ImagesPlugin.create(this)) +// .usePlugin(ImagesPlugin.create(this)) .usePlugin(IconPlugin.create(IconSpanProvider.create(this, 0))) .build(); diff --git a/sample/src/main/java/ru/noties/markwon/sample/customextension/IconPlugin.java b/sample/src/main/java/ru/noties/markwon/sample/customextension/IconPlugin.java index cbd2348b..42b7c8d8 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/customextension/IconPlugin.java +++ b/sample/src/main/java/ru/noties/markwon/sample/customextension/IconPlugin.java @@ -21,12 +21,12 @@ public class IconPlugin extends AbstractMarkwonPlugin { this.iconSpanProvider = iconSpanProvider; } - @NonNull - @Override - public Priority priority() { - // define images dependency - return Priority.after(ImagesPlugin.class); - } +// @NonNull +// @Override +// public Priority priority() { +// // define images dependency +// return Priority.after(ImagesPlugin.class); +// } @Override public void configureParser(@NonNull Parser.Builder builder) { diff --git a/sample/src/main/java/ru/noties/markwon/sample/latex/LatexActivity.java b/sample/src/main/java/ru/noties/markwon/sample/latex/LatexActivity.java index 40da04f7..ec4cd397 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/latex/LatexActivity.java +++ b/sample/src/main/java/ru/noties/markwon/sample/latex/LatexActivity.java @@ -42,7 +42,7 @@ public class LatexActivity extends Activity { + latex + "$$\n\n something like **this**"; final Markwon markwon = Markwon.builder(this) - .usePlugin(ImagesPlugin.create(this)) +// .usePlugin(ImagesPlugin.create(this)) .usePlugin(JLatexMathPlugin.create(textView.getTextSize())) .build(); diff --git a/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java b/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java index 2bed0452..7b7cc091 100644 --- a/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java +++ b/sample/src/main/java/ru/noties/markwon/sample/recycler/RecyclerActivity.java @@ -26,7 +26,11 @@ import ru.noties.markwon.MarkwonConfiguration; import ru.noties.markwon.MarkwonVisitor; import ru.noties.markwon.core.CorePlugin; import ru.noties.markwon.html.HtmlPlugin; -import ru.noties.markwon.image.svg.SvgPlugin; +import ru.noties.markwon.image.DefaultImageMediaDecoder; +import ru.noties.markwon.image.ImagesPlugin; +import ru.noties.markwon.image.file.FileSchemeHandler; +import ru.noties.markwon.image.network.OkHttpNetworkSchemeHandler; +import ru.noties.markwon.image.svg.SvgMediaDecoder; import ru.noties.markwon.recycler.MarkwonAdapter; import ru.noties.markwon.recycler.SimpleEntry; import ru.noties.markwon.recycler.table.TableEntry; @@ -73,8 +77,13 @@ public class RecyclerActivity extends Activity { private static Markwon markwon(@NonNull Context context) { return Markwon.builder(context) .usePlugin(CorePlugin.create()) - .usePlugin(ImagesPlugin.createWithAssets(context)) - .usePlugin(SvgPlugin.create(context.getResources())) + .usePlugin(ImagesPlugin.create(plugin -> { + plugin + .addSchemeHandler(FileSchemeHandler.createWithAssets(context.getAssets())) + .addSchemeHandler(OkHttpNetworkSchemeHandler.create()) + .addMediaDecoder(SvgMediaDecoder.create()) + .defaultMediaDecoder(DefaultImageMediaDecoder.create()); + })) // important to use TableEntryPlugin instead of TablePlugin .usePlugin(TableEntryPlugin.create(context)) .usePlugin(HtmlPlugin.create())