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) * `Markwon.builder` won't require CorePlugin registration (it is done automatically)
to create a builder without CorePlugin - use `Markwon#builderNoCore` 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.Looper;
import android.os.SystemClock; import android.os.SystemClock;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Spanned; import android.text.Spanned;
import android.text.style.DynamicDrawableSpan;
import android.view.View; import android.view.View;
import android.widget.TextView; import android.widget.TextView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import ru.noties.markwon.renderer.R; import ru.noties.markwon.renderer.R;
public abstract class AsyncDrawableScheduler { public abstract class AsyncDrawableScheduler {
public static void schedule(@NonNull final TextView textView) { public static void schedule(@NonNull final TextView textView) {
final List<AsyncDrawable> list = extract(textView); // we need a simple check if current text has already scheduled drawables
if (list.size() > 0) { // 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) { if (textView.getTag(R.id.markwon_drawables_scheduler) == null) {
final View.OnAttachStateChangeListener listener = new View.OnAttachStateChangeListener() { final View.OnAttachStateChangeListener listener = new View.OnAttachStateChangeListener() {
@ -41,7 +55,10 @@ public abstract class AsyncDrawableScheduler {
textView.setTag(R.id.markwon_drawables_scheduler, listener); 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())); drawable.setCallback2(new DrawableCallbackImpl(textView, drawable.getBounds()));
} }
} }
@ -49,57 +66,39 @@ public abstract class AsyncDrawableScheduler {
// must be called when text manually changed in TextView // must be called when text manually changed in TextView
public static void unschedule(@NonNull TextView view) { 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 = textView.getText();
final CharSequence cs = view.getText();
final int length = cs != null final int length = cs != null
? cs.length() ? cs.length()
: 0; : 0;
if (length == 0 || !(cs instanceof Spanned)) { if (length == 0
//noinspection unchecked || !(cs instanceof Spanned)) {
list = Collections.EMPTY_LIST; return null;
} 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());
}
}
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;
}
} }
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() { private AsyncDrawableScheduler() {

View File

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

View File

@ -10,7 +10,7 @@ import static org.junit.Assert.assertEquals;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE) @Config(manifest = Config.NONE)
public class AbstractMarkwonPluginTest { public class AbstractMarkwonPluginTest {
@Test @Test
public void process_markdown() { public void process_markdown() {
// returns supplied argument (no-op) // returns supplied argument (no-op)

View File

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

View File

@ -1,24 +1,31 @@
package ru.noties.markwon.ext.latex; package ru.noties.markwon.ext.latex;
import android.graphics.drawable.Drawable; 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.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.annotation.Px; import android.support.annotation.Px;
import android.text.Spanned;
import android.widget.TextView;
import org.commonmark.node.Image;
import org.commonmark.parser.Parser; import org.commonmark.parser.Parser;
import java.io.ByteArrayInputStream; import java.util.HashMap;
import java.io.InputStream; import java.util.Map;
import java.io.UnsupportedEncodingException; import java.util.concurrent.ExecutorService;
import java.util.Scanner; import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import ru.noties.jlatexmath.JLatexMathDrawable; import ru.noties.jlatexmath.JLatexMathDrawable;
import ru.noties.markwon.AbstractMarkwonPlugin; import ru.noties.markwon.AbstractMarkwonPlugin;
import ru.noties.markwon.MarkwonConfiguration;
import ru.noties.markwon.MarkwonVisitor; 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.AsyncDrawableLoader;
import ru.noties.markwon.image.AsyncDrawableScheduler;
import ru.noties.markwon.image.AsyncDrawableSpan;
import ru.noties.markwon.image.ImageSize; import ru.noties.markwon.image.ImageSize;
/** /**
@ -65,27 +72,29 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
private final int padding; private final int padding;
// @since 4.0.0-SNAPSHOT
private final ExecutorService executorService;
Config(@NonNull Builder builder) { Config(@NonNull Builder builder) {
this.textSize = builder.textSize; this.textSize = builder.textSize;
this.background = builder.background; this.background = builder.background;
this.align = builder.align; this.align = builder.align;
this.fitCanvas = builder.fitCanvas; this.fitCanvas = builder.fitCanvas;
this.padding = builder.padding; this.padding = builder.padding;
// @since 4.0.0-SNAPSHOT
ExecutorService executorService = builder.executorService;
if (executorService == null) {
executorService = Executors.newCachedThreadPool();
}
this.executorService = executorService;
} }
} }
@NonNull private final JLatextAsyncDrawableLoader jLatextAsyncDrawableLoader;
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;
JLatexMathPlugin(@NonNull Config config) { JLatexMathPlugin(@NonNull Config config) {
this.config = config; this.jLatextAsyncDrawableLoader = new JLatextAsyncDrawableLoader(config);
} }
@Override @Override
@ -102,70 +111,36 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
final String latex = jLatexMathBlock.latex(); final String latex = jLatexMathBlock.latex();
final int length = visitor.length(); final int length = visitor.length();
visitor.builder().append(latex); visitor.builder().append(latex);
final RenderProps renderProps = visitor.renderProps(); final MarkwonConfiguration configuration = visitor.configuration();
ImageProps.DESTINATION.set(renderProps, makeDestination(latex)); final AsyncDrawableSpan span = new AsyncDrawableSpan(
ImageProps.REPLACEMENT_TEXT_IS_LINK.set(renderProps, false); configuration.theme(),
ImageProps.IMAGE_SIZE.set(renderProps, new ImageSize(new ImageSize.Dimension(100, "%"), null)); 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 @Override
public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
builder AsyncDrawableScheduler.unschedule(textView);
.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();
}
});
} }
@NonNull
@Override @Override
public Priority priority() { public void afterSetText(@NonNull TextView textView) {
return Priority.after(ImagesPlugin.class); AsyncDrawableScheduler.schedule(textView);
} }
public static class Builder { public static class Builder {
@ -181,6 +156,9 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
private int padding; private int padding;
// @since 4.0.0-SNAPSHOT
private ExecutorService executorService;
Builder(float textSize) { Builder(float textSize) {
this.textSize = textSize; this.textSize = textSize;
} }
@ -209,9 +187,95 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
return this; return this;
} }
/**
* @since 4.0.0-SNAPSHOT
*/
@NonNull
public Builder executorService(@NonNull ExecutorService executorService) {
this.executorService = executorService;
return this;
}
@NonNull @NonNull
public Config build() { public Config build() {
return new Config(this); 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['prism4j']
implementation it['debug'] implementation it['debug']
implementation it['adapt'] implementation it['adapt']
implementation it['android-svg']
} }
deps['annotationProcessor'].with { deps['annotationProcessor'].with {

View File

@ -19,9 +19,8 @@ import ru.noties.markwon.MarkwonConfiguration;
import ru.noties.markwon.MarkwonPlugin; import ru.noties.markwon.MarkwonPlugin;
import ru.noties.markwon.MarkwonSpansFactory; import ru.noties.markwon.MarkwonSpansFactory;
import ru.noties.markwon.MarkwonVisitor; import ru.noties.markwon.MarkwonVisitor;
import ru.noties.markwon.movement.MovementMethodPlugin;
import ru.noties.markwon.core.MarkwonTheme; import ru.noties.markwon.core.MarkwonTheme;
import ru.noties.markwon.image.AsyncDrawableLoader; import ru.noties.markwon.movement.MovementMethodPlugin;
public class BasicPluginsActivity extends Activity { 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 String markdown = "![image](myownscheme://en.wikipedia.org/static/images/project-logos/enwiki-2x.png)";
final Markwon markwon = Markwon.builder(this) final Markwon markwon = Markwon.builder(this)
.usePlugin(ImagesPlugin.create(this)) // .usePlugin(ImagesPlugin.create(this))
.usePlugin(new AbstractMarkwonPlugin() { // .usePlugin(new AbstractMarkwonPlugin() {
@Override // @Override
public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) { // public void configureImages(@NonNull AsyncDrawableLoader.Builder builder) {
// we can have a custom SchemeHandler // // we can have a custom SchemeHandler
// here we will just use networkSchemeHandler to redirect call // // here we will just use networkSchemeHandler to redirect call
builder.addSchemeHandler("myownscheme", new SchemeHandler() { // builder.addSchemeHandler("myownscheme", new SchemeHandler() {
//
final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create(); // final NetworkSchemeHandler networkSchemeHandler = NetworkSchemeHandler.create();
//
@Nullable // @Nullable
@Override // @Override
public ImageItem handle(@NonNull String raw, @NonNull Uri uri) { // public ImageItem handle(@NonNull String raw, @NonNull Uri uri) {
raw = raw.replace("myownscheme", "https"); // raw = raw.replace("myownscheme", "https");
return networkSchemeHandler.handle(raw, Uri.parse(raw)); // return networkSchemeHandler.handle(raw, Uri.parse(raw));
} // }
}); // });
} // }
}) // })
.build(); .build();
markwon.setMarkdown(textView, markdown); markwon.setMarkdown(textView, markdown);

View File

@ -26,7 +26,7 @@ public class CustomExtensionActivity extends Activity {
// `usePlugin` call // `usePlugin` call
final Markwon markwon = Markwon.builder(this) final Markwon markwon = Markwon.builder(this)
// try commenting out this line to see runtime dependency resolution // 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))) .usePlugin(IconPlugin.create(IconSpanProvider.create(this, 0)))
.build(); .build();

View File

@ -21,12 +21,12 @@ public class IconPlugin extends AbstractMarkwonPlugin {
this.iconSpanProvider = iconSpanProvider; this.iconSpanProvider = iconSpanProvider;
} }
@NonNull // @NonNull
@Override // @Override
public Priority priority() { // public Priority priority() {
// define images dependency // // define images dependency
return Priority.after(ImagesPlugin.class); // return Priority.after(ImagesPlugin.class);
} // }
@Override @Override
public void configureParser(@NonNull Parser.Builder builder) { public void configureParser(@NonNull Parser.Builder builder) {

View File

@ -42,7 +42,7 @@ public class LatexActivity extends Activity {
+ latex + "$$\n\n something like **this**"; + latex + "$$\n\n something like **this**";
final Markwon markwon = Markwon.builder(this) final Markwon markwon = Markwon.builder(this)
.usePlugin(ImagesPlugin.create(this)) // .usePlugin(ImagesPlugin.create(this))
.usePlugin(JLatexMathPlugin.create(textView.getTextSize())) .usePlugin(JLatexMathPlugin.create(textView.getTextSize()))
.build(); .build();

View File

@ -26,7 +26,11 @@ import ru.noties.markwon.MarkwonConfiguration;
import ru.noties.markwon.MarkwonVisitor; import ru.noties.markwon.MarkwonVisitor;
import ru.noties.markwon.core.CorePlugin; import ru.noties.markwon.core.CorePlugin;
import ru.noties.markwon.html.HtmlPlugin; 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.MarkwonAdapter;
import ru.noties.markwon.recycler.SimpleEntry; import ru.noties.markwon.recycler.SimpleEntry;
import ru.noties.markwon.recycler.table.TableEntry; import ru.noties.markwon.recycler.table.TableEntry;
@ -73,8 +77,13 @@ public class RecyclerActivity extends Activity {
private static Markwon markwon(@NonNull Context context) { private static Markwon markwon(@NonNull Context context) {
return Markwon.builder(context) return Markwon.builder(context)
.usePlugin(CorePlugin.create()) .usePlugin(CorePlugin.create())
.usePlugin(ImagesPlugin.createWithAssets(context)) .usePlugin(ImagesPlugin.create(plugin -> {
.usePlugin(SvgPlugin.create(context.getResources())) plugin
.addSchemeHandler(FileSchemeHandler.createWithAssets(context.getAssets()))
.addSchemeHandler(OkHttpNetworkSchemeHandler.create())
.addMediaDecoder(SvgMediaDecoder.create())
.defaultMediaDecoder(DefaultImageMediaDecoder.create());
}))
// important to use TableEntryPlugin instead of TablePlugin // important to use TableEntryPlugin instead of TablePlugin
.usePlugin(TableEntryPlugin.create(context)) .usePlugin(TableEntryPlugin.create(context))
.usePlugin(HtmlPlugin.create()) .usePlugin(HtmlPlugin.create())