Update jlatex plugin to be independent of images

This commit is contained in:
Dimitry Ivanov 2019-05-29 13:41:40 +03:00
parent 64af306e53
commit e35d3ad044
12 changed files with 230 additions and 152 deletions

View File

@ -1,2 +1,6 @@
* `Markwon.builder` won't require CorePlugin registration (it is done automatically)
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

View File

@ -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<AsyncDrawable> 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<AsyncDrawable> extract(@NonNull TextView view) {
@Nullable
private static AsyncDrawableSpan[] extractSpans(@NonNull TextView textView) {
final List<AsyncDrawable> 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<AsyncDrawable> 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());
}
if (length == 0
|| !(cs instanceof Spanned)) {
return null;
}
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);
}
}
}
// we also could've tried the `nextSpanTransition`, but strangely it leads to worse performance
// then direct getSpans
if (drawables.size() == 0) {
//noinspection unchecked
list = Collections.EMPTY_LIST;
} else {
list = drawables;
}
}
return list;
return ((Spanned) cs).getSpans(0, length, AsyncDrawableSpan.class);
}
private AsyncDrawableScheduler() {

View File

@ -2,5 +2,6 @@
<resources>
<item name="markwon_drawables_scheduler" type="id" />
<item name="markwon_drawables_scheduler_last_text_hashcode" type="id" />
</resources>

View File

@ -16,6 +16,7 @@ android {
dependencies {
api project(':markwon-core')
api project(':markwon-image')
api deps['jlatexmath-android']
}

View File

@ -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
public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
AsyncDrawableScheduler.unschedule(textView);
}
@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();
}
});
}
@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<AsyncDrawable, Future<?>> 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;
}
}
}

View File

@ -50,6 +50,7 @@ dependencies {
implementation it['prism4j']
implementation it['debug']
implementation it['adapt']
implementation it['android-svg']
}
deps['annotationProcessor'].with {

View File

@ -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);

View File

@ -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();

View File

@ -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) {

View File

@ -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();

View File

@ -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())