markdownSpanType();
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java
new file mode 100644
index 00000000..94770ae4
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditor.java
@@ -0,0 +1,189 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+
+import androidx.annotation.NonNull;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import io.noties.markwon.Markwon;
+
+/**
+ * @see #builder(Markwon)
+ * @see #create(Markwon)
+ * @see #process(Editable)
+ * @see #preRender(Editable, PreRenderResultListener)
+ * @since 4.2.0
+ */
+public abstract class MarkwonEditor {
+
+ /**
+ * @see #preRender(Editable, PreRenderResultListener)
+ */
+ public interface PreRenderResult {
+
+ /**
+ * @return Editable instance for which result was calculated. This must not be
+ * actual Editable of EditText
+ */
+ @NonNull
+ Editable resultEditable();
+
+ /**
+ * Dispatch pre-rendering result to EditText
+ *
+ * @param editable to dispatch result to
+ */
+ void dispatchTo(@NonNull Editable editable);
+ }
+
+ /**
+ * @see #preRender(Editable, PreRenderResultListener)
+ */
+ public interface PreRenderResultListener {
+ void onPreRenderResult(@NonNull PreRenderResult result);
+ }
+
+ /**
+ * Creates default instance of {@link MarkwonEditor}. By default it will handle only
+ * punctuation spans (highlight markdown punctuation and nothing more).
+ *
+ * @see #builder(Markwon)
+ */
+ @NonNull
+ public static MarkwonEditor create(@NonNull Markwon markwon) {
+ return builder(markwon).build();
+ }
+
+ /**
+ * @see #create(Markwon)
+ * @see Builder
+ */
+ @NonNull
+ public static Builder builder(@NonNull Markwon markwon) {
+ return new Builder(markwon);
+ }
+
+ /**
+ * Synchronous method that processes supplied Editable in-place. If you wish to move this job
+ * to another thread consider using {@link #preRender(Editable, PreRenderResultListener)}
+ *
+ * @param editable to process
+ * @see #preRender(Editable, PreRenderResultListener)
+ */
+ public abstract void process(@NonNull Editable editable);
+
+ /**
+ * Pre-render highlight result. Can be useful to create highlight information on a different
+ * thread.
+ *
+ * Please note that currently only `setSpan` and `removeSpan` actions will be recorded (and thus dispatched).
+ * Make sure you use only these methods in your {@link EditHandler}, or implement the required
+ * functionality some other way.
+ *
+ * @param editable to process and pre-render
+ * @param preRenderListener listener to be notified when pre-render result will be ready
+ * @see #process(Editable)
+ */
+ public abstract void preRender(@NonNull Editable editable, @NonNull PreRenderResultListener preRenderListener);
+
+
+ public static class Builder {
+
+ private final Markwon markwon;
+ private final PersistedSpans.Provider persistedSpansProvider = PersistedSpans.provider();
+ private final Map, EditHandler> editHandlers = new HashMap<>(0);
+
+ private Class> punctuationSpanType;
+
+ Builder(@NonNull Markwon markwon) {
+ this.markwon = markwon;
+ }
+
+ @NonNull
+ public Builder useEditHandler(@NonNull EditHandler handler) {
+ this.editHandlers.put(handler.markdownSpanType(), handler);
+ return this;
+ }
+
+
+ /**
+ * Specify which punctuation span will be used.
+ *
+ * @param type of the span
+ * @param factory to create a new instance of the span
+ */
+ @NonNull
+ public Builder punctuationSpan(@NonNull Class type, @NonNull PersistedSpans.SpanFactory factory) {
+ this.punctuationSpanType = type;
+ this.persistedSpansProvider.persistSpan(type, factory);
+ return this;
+ }
+
+ @NonNull
+ public MarkwonEditor build() {
+
+ Class> punctuationSpanType = this.punctuationSpanType;
+ if (punctuationSpanType == null) {
+ punctuationSpan(PunctuationSpan.class, new PersistedSpans.SpanFactory() {
+ @NonNull
+ @Override
+ public PunctuationSpan create() {
+ return new PunctuationSpan();
+ }
+ });
+ punctuationSpanType = this.punctuationSpanType;
+ }
+
+ for (EditHandler handler : editHandlers.values()) {
+ handler.init(markwon);
+ handler.configurePersistedSpans(persistedSpansProvider);
+ }
+
+ final SpansHandler spansHandler = editHandlers.size() == 0
+ ? null
+ : new SpansHandlerImpl(editHandlers);
+
+ return new MarkwonEditorImpl(
+ markwon,
+ persistedSpansProvider,
+ punctuationSpanType,
+ spansHandler);
+ }
+ }
+
+ interface SpansHandler {
+ void handle(
+ @NonNull PersistedSpans spans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull Object span,
+ int spanStart,
+ int spanTextLength);
+ }
+
+ static class SpansHandlerImpl implements SpansHandler {
+
+ private final Map, EditHandler> spanHandlers;
+
+ SpansHandlerImpl(@NonNull Map, EditHandler> spanHandlers) {
+ this.spanHandlers = spanHandlers;
+ }
+
+ @Override
+ public void handle(
+ @NonNull PersistedSpans spans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull Object span,
+ int spanStart,
+ int spanTextLength) {
+ final EditHandler handler = spanHandlers.get(span.getClass());
+ if (handler != null) {
+ //noinspection unchecked
+ handler.handleMarkdownSpan(spans, editable, input, span, spanStart, spanTextLength);
+ }
+ }
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java
new file mode 100644
index 00000000..22783da5
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorImpl.java
@@ -0,0 +1,213 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import io.noties.markwon.Markwon;
+import io.noties.markwon.editor.diff_match_patch.Diff;
+
+class MarkwonEditorImpl extends MarkwonEditor {
+
+ private final Markwon markwon;
+ private final PersistedSpans.Provider persistedSpansProvider;
+ private final Class> punctuationSpanType;
+
+ @Nullable
+ private final SpansHandler spansHandler;
+
+ MarkwonEditorImpl(
+ @NonNull Markwon markwon,
+ @NonNull PersistedSpans.Provider persistedSpansProvider,
+ @NonNull Class> punctuationSpanType,
+ @Nullable SpansHandler spansHandler) {
+ this.markwon = markwon;
+ this.persistedSpansProvider = persistedSpansProvider;
+ this.punctuationSpanType = punctuationSpanType;
+ this.spansHandler = spansHandler;
+ }
+
+ @Override
+ public void process(@NonNull Editable editable) {
+
+ final String input = editable.toString();
+
+ // NB, we cast to Spannable here without prior checks
+ // if by some occasion Markwon stops returning here a Spannable our tests will catch that
+ // (we need Spannable in order to remove processed spans, so they do not appear multiple times)
+ final Spannable renderedMarkdown = (Spannable) markwon.toMarkdown(input);
+
+ final String markdown = renderedMarkdown.toString();
+
+ final SpansHandler spansHandler = this.spansHandler;
+ final boolean hasAdditionalSpans = spansHandler != null;
+
+ final PersistedSpans persistedSpans = persistedSpansProvider.provide(editable);
+ try {
+
+ final List diffs = diff_match_patch.diff_main(input, markdown);
+
+ int inputLength = 0;
+ int markdownLength = 0;
+
+ for (Diff diff : diffs) {
+
+ switch (diff.operation) {
+
+ case DELETE:
+
+ final int start = inputLength;
+ inputLength += diff.text.length();
+
+ editable.setSpan(
+ persistedSpans.get(punctuationSpanType),
+ start,
+ inputLength,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+
+ if (hasAdditionalSpans) {
+ // obtain spans for a single character of renderedMarkdown
+ // editable here should return all spans that are contained in specified
+ // region. Later we match if span starts at current position
+ // and notify additional span handler about it
+ final Object[] spans = renderedMarkdown.getSpans(markdownLength, markdownLength + 1, Object.class);
+ for (Object span : spans) {
+ if (markdownLength == renderedMarkdown.getSpanStart(span)) {
+
+ spansHandler.handle(
+ persistedSpans,
+ editable,
+ input,
+ span,
+ start,
+ renderedMarkdown.getSpanEnd(span) - markdownLength);
+ // NB, we do not break here in case of SpanFactory
+ // returns multiple spans for a markdown node, this way
+ // we will handle all of them
+
+ // It is important to remove span after we have processed it
+ // as we process them in 2 places: here and in EQUAL
+ renderedMarkdown.removeSpan(span);
+ }
+ }
+ }
+ break;
+
+ case INSERT:
+ // no special handling here, but still we must advance the markdownLength
+ markdownLength += diff.text.length();
+ break;
+
+ case EQUAL:
+ final int length = diff.text.length();
+ final int inputStart = inputLength;
+ final int markdownStart = markdownLength;
+ inputLength += length;
+ markdownLength += length;
+
+ // it is possible that there are spans for the text that is the same
+ // for example, if some links were _autolinked_ (text is the same,
+ // but there is an additional URLSpan)
+ if (hasAdditionalSpans) {
+ final Object[] spans = renderedMarkdown.getSpans(markdownStart, markdownLength, Object.class);
+ for (Object span : spans) {
+ final int spanStart = renderedMarkdown.getSpanStart(span);
+ if (spanStart >= markdownStart) {
+ final int end = renderedMarkdown.getSpanEnd(span);
+ if (end <= markdownLength) {
+
+ spansHandler.handle(
+ persistedSpans,
+ editable,
+ input,
+ span,
+ // shift span to input position (can be different from the text itself)
+ inputStart + (spanStart - markdownStart),
+ end - spanStart
+ );
+
+ renderedMarkdown.removeSpan(span);
+ }
+ }
+ }
+ }
+ break;
+
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ } finally {
+ persistedSpans.removeUnused();
+ }
+ }
+
+ @Override
+ public void preRender(@NonNull final Editable editable, @NonNull PreRenderResultListener listener) {
+ final RecordingSpannableStringBuilder builder = new RecordingSpannableStringBuilder(editable);
+ process(builder);
+ listener.onPreRenderResult(new PreRenderResult() {
+ @NonNull
+ @Override
+ public Editable resultEditable() {
+ // if they are the same, they should be equals then (what about additional spans?? like cursor? it should not interfere....)
+ return builder;
+ }
+
+ @Override
+ public void dispatchTo(@NonNull Editable e) {
+ for (Span span : builder.applied) {
+ e.setSpan(span.what, span.start, span.end, span.flags);
+ }
+ for (Object span : builder.removed) {
+ e.removeSpan(span);
+ }
+ }
+ });
+ }
+
+ private static class Span {
+ final Object what;
+ final int start;
+ final int end;
+ final int flags;
+
+ Span(Object what, int start, int end, int flags) {
+ this.what = what;
+ this.start = start;
+ this.end = end;
+ this.flags = flags;
+ }
+ }
+
+ private static class RecordingSpannableStringBuilder extends SpannableStringBuilder {
+
+ final List applied = new ArrayList<>(3);
+ final List removed = new ArrayList<>(0);
+
+ RecordingSpannableStringBuilder(CharSequence text) {
+ super(text);
+ }
+
+ @Override
+ public void setSpan(Object what, int start, int end, int flags) {
+ super.setSpan(what, start, end, flags);
+ applied.add(new Span(what, start, end, flags));
+ }
+
+ @Override
+ public void removeSpan(Object what) {
+ super.removeSpan(what);
+ removed.add(what);
+ }
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java
new file mode 100644
index 00000000..f643d580
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorTextWatcher.java
@@ -0,0 +1,177 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+import android.text.SpannableStringBuilder;
+import android.text.TextWatcher;
+import android.view.View;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+/**
+ * Implementation of TextWatcher that uses {@link MarkwonEditor#process(Editable)} method
+ * to apply markdown highlighting right after text changes.
+ *
+ * @see MarkwonEditor#process(Editable)
+ * @see MarkwonEditor#preRender(Editable, MarkwonEditor.PreRenderResultListener)
+ * @see #withProcess(MarkwonEditor)
+ * @see #withPreRender(MarkwonEditor, ExecutorService, EditText)
+ * @since 4.2.0
+ */
+public abstract class MarkwonEditorTextWatcher implements TextWatcher {
+
+ @NonNull
+ public static MarkwonEditorTextWatcher withProcess(@NonNull MarkwonEditor editor) {
+ return new WithProcess(editor);
+ }
+
+ @NonNull
+ public static MarkwonEditorTextWatcher withPreRender(
+ @NonNull MarkwonEditor editor,
+ @NonNull ExecutorService executorService,
+ @NonNull EditText editText) {
+ return new WithPreRender(editor, executorService, editText);
+ }
+
+ @Override
+ public abstract void afterTextChanged(Editable s);
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+
+ static class WithProcess extends MarkwonEditorTextWatcher {
+
+ private final MarkwonEditor editor;
+
+ private boolean selfChange;
+
+ WithProcess(@NonNull MarkwonEditor editor) {
+ this.editor = editor;
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+
+ if (selfChange) {
+ return;
+ }
+
+ selfChange = true;
+ try {
+ editor.process(s);
+ } finally {
+ selfChange = false;
+ }
+ }
+ }
+
+ static class WithPreRender extends MarkwonEditorTextWatcher {
+
+ private final MarkwonEditor editor;
+ private final ExecutorService executorService;
+
+ // As we operate on a single thread (main) we are fine with a regular int
+ // for marking current _generation_
+ private int generator;
+
+ @Nullable
+ private EditText editText;
+
+ private Future> future;
+
+ private boolean selfChange;
+
+ WithPreRender(
+ @NonNull MarkwonEditor editor,
+ @NonNull ExecutorService executorService,
+ @NonNull EditText editText) {
+ this.editor = editor;
+ this.executorService = executorService;
+ this.editText = editText;
+ this.editText.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View v) {
+
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ WithPreRender.this.editText = null;
+ }
+ });
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+
+ if (selfChange) {
+ return;
+ }
+
+ // both will be the same here (generator incremented and key assigned incremented value)
+ final int key = ++this.generator;
+
+ if (future != null) {
+ future.cancel(true);
+ }
+
+ // copy current content (it's not good to pass EditText editable to other thread)
+ final SpannableStringBuilder builder = new SpannableStringBuilder(s);
+
+ future = executorService.submit(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ editor.preRender(builder, new MarkwonEditor.PreRenderResultListener() {
+ @Override
+ public void onPreRenderResult(@NonNull final MarkwonEditor.PreRenderResult result) {
+ final EditText et = editText;
+ if (et != null) {
+ et.post(new Runnable() {
+ @Override
+ public void run() {
+ if (key == generator) {
+ final EditText et = editText;
+ if (et != null) {
+ selfChange = true;
+ try {
+ result.dispatchTo(editText.getText());
+ } finally {
+ selfChange = false;
+ }
+ }
+ }
+ }
+ });
+ }
+ }
+ });
+ } catch (final Throwable t) {
+ final EditText et = editText;
+ if (et != null) {
+ // propagate exception to main thread
+ et.post(new Runnable() {
+ @Override
+ public void run() {
+ throw new RuntimeException(t);
+ }
+ });
+ }
+ }
+ }
+ });
+ }
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java
new file mode 100644
index 00000000..3914f441
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/MarkwonEditorUtils.java
@@ -0,0 +1,183 @@
+package io.noties.markwon.editor;
+
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @since 4.2.0
+ */
+public abstract class MarkwonEditorUtils {
+
+ @NonNull
+ public static Map, List> extractSpans(@NonNull Spanned spanned, @NonNull Collection> types) {
+
+ final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
+ final Map, List> map = new HashMap<>(3);
+
+ Class> type;
+
+ for (Object span : spans) {
+ type = span.getClass();
+ if (types.contains(type)) {
+ List list = map.get(type);
+ if (list == null) {
+ list = new ArrayList<>(3);
+ map.put(type, list);
+ }
+ list.add(span);
+ }
+ }
+
+ return map;
+ }
+
+ public interface Match {
+
+ @NonNull
+ String delimiter();
+
+ int start();
+
+ int end();
+ }
+
+ @Nullable
+ public static Match findDelimited(@NonNull String input, int startFrom, @NonNull String delimiter) {
+ final int start = input.indexOf(delimiter, startFrom);
+ if (start > -1) {
+ final int length = delimiter.length();
+ final int end = input.indexOf(delimiter, start + length);
+ if (end > -1) {
+ return new MatchImpl(delimiter, start, end + length);
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ public static Match findDelimited(
+ @NonNull String input,
+ int start,
+ @NonNull String delimiter1,
+ @NonNull String delimiter2) {
+
+ final int l1 = delimiter1.length();
+ final int l2 = delimiter2.length();
+
+ final char c1 = delimiter1.charAt(0);
+ final char c2 = delimiter2.charAt(0);
+
+ char c;
+ char previousC = 0;
+
+ Match match;
+
+ for (int i = start, length = input.length(); i < length; i++) {
+ c = input.charAt(i);
+
+ // if this char is the same as previous (and we obviously have no match) -> skip
+ if (c == previousC) {
+ continue;
+ }
+
+ if (c == c1) {
+ match = matchDelimiter(input, i, length, delimiter1, l1);
+ if (match != null) {
+ return match;
+ }
+ } else if (c == c2) {
+ match = matchDelimiter(input, i, length, delimiter2, l2);
+ if (match != null) {
+ return match;
+ }
+ }
+
+ previousC = c;
+ }
+
+ return null;
+ }
+
+ // This method assumes that first char is matched already
+ @Nullable
+ private static Match matchDelimiter(
+ @NonNull String input,
+ int start,
+ int length,
+ @NonNull String delimiter,
+ int delimiterLength) {
+
+ if (start + delimiterLength < length) {
+
+ boolean result = true;
+
+ for (int i = 1; i < delimiterLength; i++) {
+ if (input.charAt(start + i) != delimiter.charAt(i)) {
+ result = false;
+ break;
+ }
+ }
+
+ if (result) {
+ // find end
+ final int end = input.indexOf(delimiter, start + delimiterLength);
+ // it's important to check if match has content
+ if (end > -1 && (end - start) > delimiterLength) {
+ return new MatchImpl(delimiter, start, end + delimiterLength);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private MarkwonEditorUtils() {
+ }
+
+ private static class MatchImpl implements Match {
+
+ private final String delimiter;
+ private final int start;
+ private final int end;
+
+ MatchImpl(@NonNull String delimiter, int start, int end) {
+ this.delimiter = delimiter;
+ this.start = start;
+ this.end = end;
+ }
+
+ @NonNull
+ @Override
+ public String delimiter() {
+ return delimiter;
+ }
+
+ @Override
+ public int start() {
+ return start;
+ }
+
+ @Override
+ public int end() {
+ return end;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return "MatchImpl{" +
+ "delimiter='" + delimiter + '\'' +
+ ", start=" + start +
+ ", end=" + end +
+ '}';
+ }
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java b/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java
new file mode 100644
index 00000000..0cb66bd6
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/PersistedSpans.java
@@ -0,0 +1,116 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+import android.text.Spannable;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static io.noties.markwon.editor.MarkwonEditorUtils.extractSpans;
+
+/**
+ * Cache for spans that present in user input. These spans are reused between different
+ * {@link MarkwonEditor#process(Editable)} and {@link MarkwonEditor#preRender(Editable, MarkwonEditor.PreRenderResultListener)}
+ * calls.
+ *
+ * @see EditHandler#handleMarkdownSpan(PersistedSpans, Editable, String, Object, int, int)
+ * @see EditHandler#configurePersistedSpans(Builder)
+ * @since 4.2.0
+ */
+public abstract class PersistedSpans {
+
+ public interface SpanFactory {
+ @NonNull
+ T create();
+ }
+
+ public interface Builder {
+ @SuppressWarnings("UnusedReturnValue")
+ @NonNull
+ Builder persistSpan(@NonNull Class type, @NonNull SpanFactory spanFactory);
+ }
+
+ @NonNull
+ public abstract T get(@NonNull Class type);
+
+ abstract void removeUnused();
+
+
+ @NonNull
+ static Provider provider() {
+ return new Provider();
+ }
+
+ static class Provider implements Builder {
+
+ private final Map, SpanFactory> map = new HashMap<>(3);
+
+ @NonNull
+ @Override
+ public Builder persistSpan(@NonNull Class type, @NonNull SpanFactory spanFactory) {
+ if (map.put(type, spanFactory) != null) {
+ Log.e("MD-EDITOR", String.format(
+ Locale.ROOT,
+ "Re-declaration of persisted span for '%s'", type.getName()));
+ }
+ return this;
+ }
+
+ @NonNull
+ PersistedSpans provide(@NonNull Spannable spannable) {
+ return new Impl(spannable, map);
+ }
+ }
+
+ static class Impl extends PersistedSpans {
+
+ private final Spannable spannable;
+ private final Map, SpanFactory> spans;
+ private final Map, List> map;
+
+ Impl(@NonNull Spannable spannable, @NonNull Map, SpanFactory> spans) {
+ this.spannable = spannable;
+ this.spans = spans;
+ this.map = extractSpans(spannable, spans.keySet());
+ }
+
+ @NonNull
+ @Override
+ public T get(@NonNull Class type) {
+
+ final Object span;
+
+ final List list = map.get(type);
+ if (list != null && list.size() > 0) {
+ span = list.remove(0);
+ } else {
+ final SpanFactory spanFactory = spans.get(type);
+ if (spanFactory == null) {
+ throw new IllegalStateException("Requested type `" + type.getName() + "` was " +
+ "not registered, use PersistedSpans.Builder#persistSpan method to register");
+ }
+ span = spanFactory.create();
+ }
+
+ //noinspection unchecked
+ return (T) span;
+ }
+
+ @Override
+ void removeUnused() {
+ for (List spans : map.values()) {
+ if (spans != null
+ && spans.size() > 0) {
+ for (Object span : spans) {
+ spannable.removeSpan(span);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/PunctuationSpan.java b/markwon-editor/src/main/java/io/noties/markwon/editor/PunctuationSpan.java
new file mode 100644
index 00000000..d8c54507
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/PunctuationSpan.java
@@ -0,0 +1,17 @@
+package io.noties.markwon.editor;
+
+import android.text.TextPaint;
+import android.text.style.CharacterStyle;
+
+import io.noties.markwon.utils.ColorUtils;
+
+class PunctuationSpan extends CharacterStyle {
+
+ private static final int DEF_PUNCTUATION_ALPHA = 75;
+
+ @Override
+ public void updateDrawState(TextPaint tp) {
+ final int color = ColorUtils.applyAlpha(tp.getColor(), DEF_PUNCTUATION_ALPHA);
+ tp.setColor(color);
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/diff_match_patch.java b/markwon-editor/src/main/java/io/noties/markwon/editor/diff_match_patch.java
new file mode 100644
index 00000000..dca9fd87
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/diff_match_patch.java
@@ -0,0 +1,2492 @@
+package io.noties.markwon.editor;
+
+/*
+ * Diff Match and Patch
+ * Copyright 2018 The diff-match-patch Authors.
+ * https://github.com/google/diff-match-patch
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/*
+ * Functions for diff, match and patch.
+ * Computes the difference between two texts to create a patch.
+ * Applies the patch onto another text, allowing for errors.
+ *
+ * @author fraser@google.com (Neil Fraser)
+ */
+
+/**
+ * Class containing the diff, match and patch methods.
+ * Also contains the behaviour settings.
+ */
+class diff_match_patch {
+
+ // Defaults.
+ // Set these on your diff_match_patch instance to override the defaults.
+
+// /**
+// * Number of seconds to map a diff before giving up (0 for infinity).
+// */
+// private static final float Diff_Timeout = 1.0f;
+// /**
+// * Cost of an empty edit operation in terms of edit characters.
+// */
+// public short Diff_EditCost = 4;
+// /**
+// * At what point is no match declared (0.0 = perfection, 1.0 = very loose).
+// */
+// public float Match_Threshold = 0.5f;
+// /**
+// * How far to search for a match (0 = exact location, 1000+ = broad match).
+// * A match this many characters away from the expected location will add
+// * 1.0 to the score (0.0 is a perfect match).
+// */
+// public int Match_Distance = 1000;
+// /**
+// * When deleting a large block of text (over ~64 characters), how close do
+// * the contents have to be to match the expected contents. (0.0 = perfection,
+// * 1.0 = very loose). Note that Match_Threshold controls how closely the
+// * end points of a delete need to match.
+// */
+// public float Patch_DeleteThreshold = 0.5f;
+// /**
+// * Chunk size for context length.
+// */
+// public short Patch_Margin = 4;
+//
+// /**
+// * The number of bits in an int.
+// */
+// private short Match_MaxBits = 32;
+
+ /**
+ * Internal class for returning results from diff_linesToChars().
+ * Other less paranoid languages just use a three-element array.
+ */
+ protected static class LinesToCharsResult {
+ protected String chars1;
+ protected String chars2;
+ protected List lineArray;
+
+ protected LinesToCharsResult(String chars1, String chars2,
+ List lineArray) {
+ this.chars1 = chars1;
+ this.chars2 = chars2;
+ this.lineArray = lineArray;
+ }
+ }
+
+
+ // DIFF FUNCTIONS
+
+
+ /**
+ * The data structure representing a diff is a Linked list of Diff objects:
+ * {Diff(Operation.DELETE, "Hello"), Diff(Operation.INSERT, "Goodbye"),
+ * Diff(Operation.EQUAL, " world.")}
+ * which means: delete "Hello", add "Goodbye" and keep " world."
+ */
+ public enum Operation {
+ DELETE,
+ INSERT,
+ EQUAL
+ }
+
+ /**
+ * Find the differences between two texts.
+ * Run a faster, slightly less optimal diff.
+ * This method allows the 'checklines' of diff_main() to be optional.
+ * Most of the time checklines is wanted, so default to true.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @return Linked List of Diff objects.
+ */
+ static LinkedList diff_main(String text1, String text2) {
+ return diff_main(text1, text2, true);
+ }
+
+// /**
+// * Find the differences between two texts.
+// *
+// * @param text1 Old string to be diffed.
+// * @param text2 New string to be diffed.
+// * @param checklines Speedup flag. If false, then don't run a
+// * line-level diff first to identify the changed areas.
+// * If true, then run a faster slightly less optimal diff.
+// * @return Linked List of Diff objects.
+// */
+// public static LinkedList diff_main(String text1, String text2,
+// boolean checklines) {
+// return diff_main(text1, text2, checklines);
+// }
+
+ /**
+ * Find the differences between two texts. Simplifies the problem by
+ * stripping any common prefix or suffix off the texts before diffing.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @param checklines Speedup flag. If false, then don't run a
+ * line-level diff first to identify the changed areas.
+ * If true, then run a faster slightly less optimal diff.
+ * @return Linked List of Diff objects.
+ */
+ private static LinkedList diff_main(String text1, String text2, boolean checklines) {
+ // Check for null inputs.
+ if (text1 == null || text2 == null) {
+ throw new IllegalArgumentException("Null inputs. (diff_main)");
+ }
+
+ // Check for equality (speedup).
+ LinkedList diffs;
+ if (text1.equals(text2)) {
+ diffs = new LinkedList();
+ if (text1.length() != 0) {
+ diffs.add(new Diff(Operation.EQUAL, text1));
+ }
+ return diffs;
+ }
+
+ // Trim off common prefix (speedup).
+ int commonlength = diff_commonPrefix(text1, text2);
+ String commonprefix = text1.substring(0, commonlength);
+ text1 = text1.substring(commonlength);
+ text2 = text2.substring(commonlength);
+
+ // Trim off common suffix (speedup).
+ commonlength = diff_commonSuffix(text1, text2);
+ String commonsuffix = text1.substring(text1.length() - commonlength);
+ text1 = text1.substring(0, text1.length() - commonlength);
+ text2 = text2.substring(0, text2.length() - commonlength);
+
+ // Compute the diff on the middle block.
+ diffs = diff_compute(text1, text2, checklines);
+
+ // Restore the prefix and suffix.
+ if (commonprefix.length() != 0) {
+ diffs.addFirst(new Diff(Operation.EQUAL, commonprefix));
+ }
+ if (commonsuffix.length() != 0) {
+ diffs.addLast(new Diff(Operation.EQUAL, commonsuffix));
+ }
+
+ diff_cleanupMerge(diffs);
+ return diffs;
+ }
+
+ /**
+ * Find the differences between two texts. Assumes that the texts do not
+ * have any common prefix or suffix.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @param checklines Speedup flag. If false, then don't run a
+ * line-level diff first to identify the changed areas.
+ * If true, then run a faster slightly less optimal diff.
+ * @return Linked List of Diff objects.
+ */
+ private static LinkedList diff_compute(String text1, String text2,
+ boolean checklines) {
+ LinkedList diffs = new LinkedList();
+
+ if (text1.length() == 0) {
+ // Just add some text (speedup).
+ diffs.add(new Diff(Operation.INSERT, text2));
+ return diffs;
+ }
+
+ if (text2.length() == 0) {
+ // Just delete some text (speedup).
+ diffs.add(new Diff(Operation.DELETE, text1));
+ return diffs;
+ }
+
+ String longtext = text1.length() > text2.length() ? text1 : text2;
+ String shorttext = text1.length() > text2.length() ? text2 : text1;
+ int i = longtext.indexOf(shorttext);
+ if (i != -1) {
+ // Shorter text is inside the longer text (speedup).
+ Operation op = (text1.length() > text2.length()) ?
+ Operation.DELETE : Operation.INSERT;
+ diffs.add(new Diff(op, longtext.substring(0, i)));
+ diffs.add(new Diff(Operation.EQUAL, shorttext));
+ diffs.add(new Diff(op, longtext.substring(i + shorttext.length())));
+ return diffs;
+ }
+
+ if (shorttext.length() == 1) {
+ // Single character string.
+ // After the previous speedup, the character can't be an equality.
+ diffs.add(new Diff(Operation.DELETE, text1));
+ diffs.add(new Diff(Operation.INSERT, text2));
+ return diffs;
+ }
+
+ // Check to see if the problem can be split in two.
+ String[] hm = diff_halfMatch(text1, text2);
+ if (hm != null) {
+ // A half-match was found, sort out the return data.
+ String text1_a = hm[0];
+ String text1_b = hm[1];
+ String text2_a = hm[2];
+ String text2_b = hm[3];
+ String mid_common = hm[4];
+ // Send both pairs off for separate processing.
+ LinkedList diffs_a = diff_main(text1_a, text2_a,
+ checklines);
+ LinkedList diffs_b = diff_main(text1_b, text2_b,
+ checklines);
+ // Merge the results.
+ diffs = diffs_a;
+ diffs.add(new Diff(Operation.EQUAL, mid_common));
+ diffs.addAll(diffs_b);
+ return diffs;
+ }
+
+ if (checklines && text1.length() > 100 && text2.length() > 100) {
+ return diff_lineMode(text1, text2);
+ }
+
+ return diff_bisect(text1, text2);
+ }
+
+ /**
+ * Do a quick line-level diff on both strings, then rediff the parts for
+ * greater accuracy.
+ * This speedup can produce non-minimal diffs.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @return Linked List of Diff objects.
+ */
+ private static LinkedList diff_lineMode(String text1, String text2) {
+ // Scan the text on a line-by-line basis first.
+ LinesToCharsResult a = diff_linesToChars(text1, text2);
+ text1 = a.chars1;
+ text2 = a.chars2;
+ List linearray = a.lineArray;
+
+ LinkedList diffs = diff_main(text1, text2, false);
+
+ // Convert the diff back to original text.
+ diff_charsToLines(diffs, linearray);
+ // Eliminate freak matches (e.g. blank lines)
+ diff_cleanupSemantic(diffs);
+
+ // Rediff any replacement blocks, this time character-by-character.
+ // Add a dummy entry at the end.
+ diffs.add(new Diff(Operation.EQUAL, ""));
+ int count_delete = 0;
+ int count_insert = 0;
+ String text_delete = "";
+ String text_insert = "";
+ ListIterator pointer = diffs.listIterator();
+ Diff thisDiff = pointer.next();
+ while (thisDiff != null) {
+ switch (thisDiff.operation) {
+ case INSERT:
+ count_insert++;
+ text_insert += thisDiff.text;
+ break;
+ case DELETE:
+ count_delete++;
+ text_delete += thisDiff.text;
+ break;
+ case EQUAL:
+ // Upon reaching an equality, check for prior redundancies.
+ if (count_delete >= 1 && count_insert >= 1) {
+ // Delete the offending records and add the merged ones.
+ pointer.previous();
+ for (int j = 0; j < count_delete + count_insert; j++) {
+ pointer.previous();
+ pointer.remove();
+ }
+ for (Diff subDiff : diff_main(text_delete, text_insert, false)) {
+ pointer.add(subDiff);
+ }
+ }
+ count_insert = 0;
+ count_delete = 0;
+ text_delete = "";
+ text_insert = "";
+ break;
+ }
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ diffs.removeLast(); // Remove the dummy entry at the end.
+
+ return diffs;
+ }
+
+ /**
+ * Find the 'middle snake' of a diff, split the problem in two
+ * and return the recursively constructed diff.
+ * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @return LinkedList of Diff objects.
+ */
+ private static LinkedList diff_bisect(String text1, String text2) {
+ // Cache the text lengths to prevent multiple calls.
+ int text1_length = text1.length();
+ int text2_length = text2.length();
+ int max_d = (text1_length + text2_length + 1) / 2;
+ int v_offset = max_d;
+ int v_length = 2 * max_d;
+ int[] v1 = new int[v_length];
+ int[] v2 = new int[v_length];
+ for (int x = 0; x < v_length; x++) {
+ v1[x] = -1;
+ v2[x] = -1;
+ }
+ v1[v_offset + 1] = 0;
+ v2[v_offset + 1] = 0;
+ int delta = text1_length - text2_length;
+ // If the total number of characters is odd, then the front path will
+ // collide with the reverse path.
+ boolean front = (delta % 2 != 0);
+ // Offsets for start and end of k loop.
+ // Prevents mapping of space beyond the grid.
+ int k1start = 0;
+ int k1end = 0;
+ int k2start = 0;
+ int k2end = 0;
+ for (int d = 0; d < max_d; d++) {
+// // Bail out if deadline is reached.
+// if (System.currentTimeMillis() > deadline) {
+// break;
+// }
+
+ // Walk the front path one step.
+ for (int k1 = -d + k1start; k1 <= d - k1end; k1 += 2) {
+ int k1_offset = v_offset + k1;
+ int x1;
+ if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) {
+ x1 = v1[k1_offset + 1];
+ } else {
+ x1 = v1[k1_offset - 1] + 1;
+ }
+ int y1 = x1 - k1;
+ while (x1 < text1_length && y1 < text2_length
+ && text1.charAt(x1) == text2.charAt(y1)) {
+ x1++;
+ y1++;
+ }
+ v1[k1_offset] = x1;
+ if (x1 > text1_length) {
+ // Ran off the right of the graph.
+ k1end += 2;
+ } else if (y1 > text2_length) {
+ // Ran off the bottom of the graph.
+ k1start += 2;
+ } else if (front) {
+ int k2_offset = v_offset + delta - k1;
+ if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) {
+ // Mirror x2 onto top-left coordinate system.
+ int x2 = text1_length - v2[k2_offset];
+ if (x1 >= x2) {
+ // Overlap detected.
+ return diff_bisectSplit(text1, text2, x1, y1);
+ }
+ }
+ }
+ }
+
+ // Walk the reverse path one step.
+ for (int k2 = -d + k2start; k2 <= d - k2end; k2 += 2) {
+ int k2_offset = v_offset + k2;
+ int x2;
+ if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) {
+ x2 = v2[k2_offset + 1];
+ } else {
+ x2 = v2[k2_offset - 1] + 1;
+ }
+ int y2 = x2 - k2;
+ while (x2 < text1_length && y2 < text2_length
+ && text1.charAt(text1_length - x2 - 1)
+ == text2.charAt(text2_length - y2 - 1)) {
+ x2++;
+ y2++;
+ }
+ v2[k2_offset] = x2;
+ if (x2 > text1_length) {
+ // Ran off the left of the graph.
+ k2end += 2;
+ } else if (y2 > text2_length) {
+ // Ran off the top of the graph.
+ k2start += 2;
+ } else if (!front) {
+ int k1_offset = v_offset + delta - k2;
+ if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) {
+ int x1 = v1[k1_offset];
+ int y1 = v_offset + x1 - k1_offset;
+ // Mirror x2 onto top-left coordinate system.
+ x2 = text1_length - x2;
+ if (x1 >= x2) {
+ // Overlap detected.
+ return diff_bisectSplit(text1, text2, x1, y1);
+ }
+ }
+ }
+ }
+ }
+ // Diff took too long and hit the deadline or
+ // number of diffs equals number of characters, no commonality at all.
+ LinkedList diffs = new LinkedList();
+ diffs.add(new Diff(Operation.DELETE, text1));
+ diffs.add(new Diff(Operation.INSERT, text2));
+ return diffs;
+ }
+
+ /**
+ * Given the location of the 'middle snake', split the diff in two parts
+ * and recurse.
+ *
+ * @param text1 Old string to be diffed.
+ * @param text2 New string to be diffed.
+ * @param x Index of split point in text1.
+ * @param y Index of split point in text2.
+ * @return LinkedList of Diff objects.
+ */
+ private static LinkedList diff_bisectSplit(String text1, String text2,
+ int x, int y) {
+ String text1a = text1.substring(0, x);
+ String text2a = text2.substring(0, y);
+ String text1b = text1.substring(x);
+ String text2b = text2.substring(y);
+
+ // Compute both diffs serially.
+ LinkedList diffs = diff_main(text1a, text2a, false);
+ LinkedList diffsb = diff_main(text1b, text2b, false);
+
+ diffs.addAll(diffsb);
+ return diffs;
+ }
+
+ /**
+ * Split two texts into a list of strings. Reduce the texts to a string of
+ * hashes where each Unicode character represents one line.
+ *
+ * @param text1 First string.
+ * @param text2 Second string.
+ * @return An object containing the encoded text1, the encoded text2 and
+ * the List of unique strings. The zeroth element of the List of
+ * unique strings is intentionally blank.
+ */
+ private static LinesToCharsResult diff_linesToChars(String text1, String text2) {
+ List lineArray = new ArrayList();
+ Map lineHash = new HashMap();
+ // e.g. linearray[4] == "Hello\n"
+ // e.g. linehash.get("Hello\n") == 4
+
+ // "\x00" is a valid character, but various debuggers don't like it.
+ // So we'll insert a junk entry to avoid generating a null character.
+ lineArray.add("");
+
+ // Allocate 2/3rds of the space for text1, the rest for text2.
+ String chars1 = diff_linesToCharsMunge(text1, lineArray, lineHash, 40000);
+ String chars2 = diff_linesToCharsMunge(text2, lineArray, lineHash, 65535);
+ return new LinesToCharsResult(chars1, chars2, lineArray);
+ }
+
+ /**
+ * Split a text into a list of strings. Reduce the texts to a string of
+ * hashes where each Unicode character represents one line.
+ *
+ * @param text String to encode.
+ * @param lineArray List of unique strings.
+ * @param lineHash Map of strings to indices.
+ * @param maxLines Maximum length of lineArray.
+ * @return Encoded string.
+ */
+ private static String diff_linesToCharsMunge(String text, List lineArray,
+ Map lineHash, int maxLines) {
+ int lineStart = 0;
+ int lineEnd = -1;
+ String line;
+ StringBuilder chars = new StringBuilder();
+ // Walk the text, pulling out a substring for each line.
+ // text.split('\n') would would temporarily double our memory footprint.
+ // Modifying text would create many large strings to garbage collect.
+ while (lineEnd < text.length() - 1) {
+ lineEnd = text.indexOf('\n', lineStart);
+ if (lineEnd == -1) {
+ lineEnd = text.length() - 1;
+ }
+ line = text.substring(lineStart, lineEnd + 1);
+
+ if (lineHash.containsKey(line)) {
+ chars.append(String.valueOf((char) (int) lineHash.get(line)));
+ } else {
+ if (lineArray.size() == maxLines) {
+ // Bail out at 65535 because
+ // String.valueOf((char) 65536).equals(String.valueOf(((char) 0)))
+ line = text.substring(lineStart);
+ lineEnd = text.length();
+ }
+ lineArray.add(line);
+ lineHash.put(line, lineArray.size() - 1);
+ chars.append(String.valueOf((char) (lineArray.size() - 1)));
+ }
+ lineStart = lineEnd + 1;
+ }
+ return chars.toString();
+ }
+
+ /**
+ * Rehydrate the text in a diff from a string of line hashes to real lines of
+ * text.
+ *
+ * @param diffs List of Diff objects.
+ * @param lineArray List of unique strings.
+ */
+ private static void diff_charsToLines(List diffs,
+ List lineArray) {
+ StringBuilder text;
+ for (Diff diff : diffs) {
+ text = new StringBuilder();
+ for (int j = 0; j < diff.text.length(); j++) {
+ text.append(lineArray.get(diff.text.charAt(j)));
+ }
+ diff.text = text.toString();
+ }
+ }
+
+ /**
+ * Determine the common prefix of two strings
+ *
+ * @param text1 First string.
+ * @param text2 Second string.
+ * @return The number of characters common to the start of each string.
+ */
+ public static int diff_commonPrefix(String text1, String text2) {
+ // Performance analysis: https://neil.fraser.name/news/2007/10/09/
+ int n = Math.min(text1.length(), text2.length());
+ for (int i = 0; i < n; i++) {
+ if (text1.charAt(i) != text2.charAt(i)) {
+ return i;
+ }
+ }
+ return n;
+ }
+
+ /**
+ * Determine the common suffix of two strings
+ *
+ * @param text1 First string.
+ * @param text2 Second string.
+ * @return The number of characters common to the end of each string.
+ */
+ private static int diff_commonSuffix(String text1, String text2) {
+ // Performance analysis: https://neil.fraser.name/news/2007/10/09/
+ int text1_length = text1.length();
+ int text2_length = text2.length();
+ int n = Math.min(text1_length, text2_length);
+ for (int i = 1; i <= n; i++) {
+ if (text1.charAt(text1_length - i) != text2.charAt(text2_length - i)) {
+ return i - 1;
+ }
+ }
+ return n;
+ }
+
+ /**
+ * Determine if the suffix of one string is the prefix of another.
+ *
+ * @param text1 First string.
+ * @param text2 Second string.
+ * @return The number of characters common to the end of the first
+ * string and the start of the second string.
+ */
+ private static int diff_commonOverlap(String text1, String text2) {
+ // Cache the text lengths to prevent multiple calls.
+ int text1_length = text1.length();
+ int text2_length = text2.length();
+ // Eliminate the null case.
+ if (text1_length == 0 || text2_length == 0) {
+ return 0;
+ }
+ // Truncate the longer string.
+ if (text1_length > text2_length) {
+ text1 = text1.substring(text1_length - text2_length);
+ } else if (text1_length < text2_length) {
+ text2 = text2.substring(0, text1_length);
+ }
+ int text_length = Math.min(text1_length, text2_length);
+ // Quick check for the worst case.
+ if (text1.equals(text2)) {
+ return text_length;
+ }
+
+ // Start by looking for a single character match
+ // and increase length until no match is found.
+ // Performance analysis: https://neil.fraser.name/news/2010/11/04/
+ int best = 0;
+ int length = 1;
+ while (true) {
+ String pattern = text1.substring(text_length - length);
+ int found = text2.indexOf(pattern);
+ if (found == -1) {
+ return best;
+ }
+ length += found;
+ if (found == 0 || text1.substring(text_length - length).equals(
+ text2.substring(0, length))) {
+ best = length;
+ length++;
+ }
+ }
+ }
+
+ /**
+ * Do the two texts share a substring which is at least half the length of
+ * the longer text?
+ * This speedup can produce non-minimal diffs.
+ *
+ * @param text1 First string.
+ * @param text2 Second string.
+ * @return Five element String array, containing the prefix of text1, the
+ * suffix of text1, the prefix of text2, the suffix of text2 and the
+ * common middle. Or null if there was no match.
+ */
+ private static String[] diff_halfMatch(String text1, String text2) {
+// if (Diff_Timeout <= 0) {
+// // Don't risk returning a non-optimal diff if we have unlimited time.
+// return null;
+// }
+ String longtext = text1.length() > text2.length() ? text1 : text2;
+ String shorttext = text1.length() > text2.length() ? text2 : text1;
+ if (longtext.length() < 4 || shorttext.length() * 2 < longtext.length()) {
+ return null; // Pointless.
+ }
+
+ // First check if the second quarter is the seed for a half-match.
+ String[] hm1 = diff_halfMatchI(longtext, shorttext,
+ (longtext.length() + 3) / 4);
+ // Check again based on the third quarter.
+ String[] hm2 = diff_halfMatchI(longtext, shorttext,
+ (longtext.length() + 1) / 2);
+ String[] hm;
+ if (hm1 == null && hm2 == null) {
+ return null;
+ } else if (hm2 == null) {
+ hm = hm1;
+ } else if (hm1 == null) {
+ hm = hm2;
+ } else {
+ // Both matched. Select the longest.
+ hm = hm1[4].length() > hm2[4].length() ? hm1 : hm2;
+ }
+
+ // A half-match was found, sort out the return data.
+ if (text1.length() > text2.length()) {
+ return hm;
+ //return new String[]{hm[0], hm[1], hm[2], hm[3], hm[4]};
+ } else {
+ return new String[]{hm[2], hm[3], hm[0], hm[1], hm[4]};
+ }
+ }
+
+ /**
+ * Does a substring of shorttext exist within longtext such that the
+ * substring is at least half the length of longtext?
+ *
+ * @param longtext Longer string.
+ * @param shorttext Shorter string.
+ * @param i Start index of quarter length substring within longtext.
+ * @return Five element String array, containing the prefix of longtext, the
+ * suffix of longtext, the prefix of shorttext, the suffix of shorttext
+ * and the common middle. Or null if there was no match.
+ */
+ private static String[] diff_halfMatchI(String longtext, String shorttext, int i) {
+ // Start with a 1/4 length substring at position i as a seed.
+ String seed = longtext.substring(i, i + longtext.length() / 4);
+ int j = -1;
+ String best_common = "";
+ String best_longtext_a = "", best_longtext_b = "";
+ String best_shorttext_a = "", best_shorttext_b = "";
+ while ((j = shorttext.indexOf(seed, j + 1)) != -1) {
+ int prefixLength = diff_commonPrefix(longtext.substring(i),
+ shorttext.substring(j));
+ int suffixLength = diff_commonSuffix(longtext.substring(0, i),
+ shorttext.substring(0, j));
+ if (best_common.length() < suffixLength + prefixLength) {
+ best_common = shorttext.substring(j - suffixLength, j)
+ + shorttext.substring(j, j + prefixLength);
+ best_longtext_a = longtext.substring(0, i - suffixLength);
+ best_longtext_b = longtext.substring(i + prefixLength);
+ best_shorttext_a = shorttext.substring(0, j - suffixLength);
+ best_shorttext_b = shorttext.substring(j + prefixLength);
+ }
+ }
+ if (best_common.length() * 2 >= longtext.length()) {
+ return new String[]{best_longtext_a, best_longtext_b,
+ best_shorttext_a, best_shorttext_b, best_common};
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Reduce the number of edits by eliminating semantically trivial equalities.
+ *
+ * @param diffs LinkedList of Diff objects.
+ */
+ private static void diff_cleanupSemantic(LinkedList diffs) {
+ if (diffs.isEmpty()) {
+ return;
+ }
+ boolean changes = false;
+ Deque equalities = new ArrayDeque(); // Double-ended queue of qualities.
+ String lastEquality = null; // Always equal to equalities.peek().text
+ ListIterator pointer = diffs.listIterator();
+ // Number of characters that changed prior to the equality.
+ int length_insertions1 = 0;
+ int length_deletions1 = 0;
+ // Number of characters that changed after the equality.
+ int length_insertions2 = 0;
+ int length_deletions2 = 0;
+ Diff thisDiff = pointer.next();
+ while (thisDiff != null) {
+ if (thisDiff.operation == Operation.EQUAL) {
+ // Equality found.
+ equalities.push(thisDiff);
+ length_insertions1 = length_insertions2;
+ length_deletions1 = length_deletions2;
+ length_insertions2 = 0;
+ length_deletions2 = 0;
+ lastEquality = thisDiff.text;
+ } else {
+ // An insertion or deletion.
+ if (thisDiff.operation == Operation.INSERT) {
+ length_insertions2 += thisDiff.text.length();
+ } else {
+ length_deletions2 += thisDiff.text.length();
+ }
+ // Eliminate an equality that is smaller or equal to the edits on both
+ // sides of it.
+ if (lastEquality != null && (lastEquality.length()
+ <= Math.max(length_insertions1, length_deletions1))
+ && (lastEquality.length()
+ <= Math.max(length_insertions2, length_deletions2))) {
+ //System.out.println("Splitting: '" + lastEquality + "'");
+ // Walk back to offending equality.
+ while (thisDiff != equalities.peek()) {
+ thisDiff = pointer.previous();
+ }
+ pointer.next();
+
+ // Replace equality with a delete.
+ pointer.set(new Diff(Operation.DELETE, lastEquality));
+ // Insert a corresponding an insert.
+ pointer.add(new Diff(Operation.INSERT, lastEquality));
+
+ equalities.pop(); // Throw away the equality we just deleted.
+ if (!equalities.isEmpty()) {
+ // Throw away the previous equality (it needs to be reevaluated).
+ equalities.pop();
+ }
+ if (equalities.isEmpty()) {
+ // There are no previous equalities, walk back to the start.
+ while (pointer.hasPrevious()) {
+ pointer.previous();
+ }
+ } else {
+ // There is a safe equality we can fall back to.
+ thisDiff = equalities.peek();
+ while (thisDiff != pointer.previous()) {
+ // Intentionally empty loop.
+ }
+ }
+
+ length_insertions1 = 0; // Reset the counters.
+ length_insertions2 = 0;
+ length_deletions1 = 0;
+ length_deletions2 = 0;
+ lastEquality = null;
+ changes = true;
+ }
+ }
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+
+ // Normalize the diff.
+ if (changes) {
+ diff_cleanupMerge(diffs);
+ }
+ diff_cleanupSemanticLossless(diffs);
+
+ // Find any overlaps between deletions and insertions.
+ // e.g: abcxxxxxxdef
+ // -> abcxxxdef
+ // e.g: xxxabcdefxxx
+ // -> def xxxabc
+ // Only extract an overlap if it is as big as the edit ahead or behind it.
+ pointer = diffs.listIterator();
+ Diff prevDiff = null;
+ thisDiff = null;
+ if (pointer.hasNext()) {
+ prevDiff = pointer.next();
+ if (pointer.hasNext()) {
+ thisDiff = pointer.next();
+ }
+ }
+ while (thisDiff != null) {
+ if (prevDiff.operation == Operation.DELETE &&
+ thisDiff.operation == Operation.INSERT) {
+ String deletion = prevDiff.text;
+ String insertion = thisDiff.text;
+ int overlap_length1 = diff_commonOverlap(deletion, insertion);
+ int overlap_length2 = diff_commonOverlap(insertion, deletion);
+ if (overlap_length1 >= overlap_length2) {
+ if (overlap_length1 >= deletion.length() / 2.0 ||
+ overlap_length1 >= insertion.length() / 2.0) {
+ // Overlap found. Insert an equality and trim the surrounding edits.
+ pointer.previous();
+ pointer.add(new Diff(Operation.EQUAL,
+ insertion.substring(0, overlap_length1)));
+ prevDiff.text =
+ deletion.substring(0, deletion.length() - overlap_length1);
+ thisDiff.text = insertion.substring(overlap_length1);
+ // pointer.add inserts the element before the cursor, so there is
+ // no need to step past the new element.
+ }
+ } else {
+ if (overlap_length2 >= deletion.length() / 2.0 ||
+ overlap_length2 >= insertion.length() / 2.0) {
+ // Reverse overlap found.
+ // Insert an equality and swap and trim the surrounding edits.
+ pointer.previous();
+ pointer.add(new Diff(Operation.EQUAL,
+ deletion.substring(0, overlap_length2)));
+ prevDiff.operation = Operation.INSERT;
+ prevDiff.text =
+ insertion.substring(0, insertion.length() - overlap_length2);
+ thisDiff.operation = Operation.DELETE;
+ thisDiff.text = deletion.substring(overlap_length2);
+ // pointer.add inserts the element before the cursor, so there is
+ // no need to step past the new element.
+ }
+ }
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ prevDiff = thisDiff;
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ }
+
+ /**
+ * Look for single edits surrounded on both sides by equalities
+ * which can be shifted sideways to align the edit to a word boundary.
+ * e.g: The cat c ame. -> The cat came.
+ *
+ * @param diffs LinkedList of Diff objects.
+ */
+ private static void diff_cleanupSemanticLossless(LinkedList diffs) {
+ String equality1, edit, equality2;
+ String commonString;
+ int commonOffset;
+ int score, bestScore;
+ String bestEquality1, bestEdit, bestEquality2;
+ // Create a new iterator at the start.
+ ListIterator pointer = diffs.listIterator();
+ Diff prevDiff = pointer.hasNext() ? pointer.next() : null;
+ Diff thisDiff = pointer.hasNext() ? pointer.next() : null;
+ Diff nextDiff = pointer.hasNext() ? pointer.next() : null;
+ // Intentionally ignore the first and last element (don't need checking).
+ while (nextDiff != null) {
+ if (prevDiff.operation == Operation.EQUAL &&
+ nextDiff.operation == Operation.EQUAL) {
+ // This is a single edit surrounded by equalities.
+ equality1 = prevDiff.text;
+ edit = thisDiff.text;
+ equality2 = nextDiff.text;
+
+ // First, shift the edit as far left as possible.
+ commonOffset = diff_commonSuffix(equality1, edit);
+ if (commonOffset != 0) {
+ commonString = edit.substring(edit.length() - commonOffset);
+ equality1 = equality1.substring(0, equality1.length() - commonOffset);
+ edit = commonString + edit.substring(0, edit.length() - commonOffset);
+ equality2 = commonString + equality2;
+ }
+
+ // Second, step character by character right, looking for the best fit.
+ bestEquality1 = equality1;
+ bestEdit = edit;
+ bestEquality2 = equality2;
+ bestScore = diff_cleanupSemanticScore(equality1, edit)
+ + diff_cleanupSemanticScore(edit, equality2);
+ while (edit.length() != 0 && equality2.length() != 0
+ && edit.charAt(0) == equality2.charAt(0)) {
+ equality1 += edit.charAt(0);
+ edit = edit.substring(1) + equality2.charAt(0);
+ equality2 = equality2.substring(1);
+ score = diff_cleanupSemanticScore(equality1, edit)
+ + diff_cleanupSemanticScore(edit, equality2);
+ // The >= encourages trailing rather than leading whitespace on edits.
+ if (score >= bestScore) {
+ bestScore = score;
+ bestEquality1 = equality1;
+ bestEdit = edit;
+ bestEquality2 = equality2;
+ }
+ }
+
+ if (!prevDiff.text.equals(bestEquality1)) {
+ // We have an improvement, save it back to the diff.
+ if (bestEquality1.length() != 0) {
+ prevDiff.text = bestEquality1;
+ } else {
+ pointer.previous(); // Walk past nextDiff.
+ pointer.previous(); // Walk past thisDiff.
+ pointer.previous(); // Walk past prevDiff.
+ pointer.remove(); // Delete prevDiff.
+ pointer.next(); // Walk past thisDiff.
+ pointer.next(); // Walk past nextDiff.
+ }
+ thisDiff.text = bestEdit;
+ if (bestEquality2.length() != 0) {
+ nextDiff.text = bestEquality2;
+ } else {
+ pointer.remove(); // Delete nextDiff.
+ nextDiff = thisDiff;
+ thisDiff = prevDiff;
+ }
+ }
+ }
+ prevDiff = thisDiff;
+ thisDiff = nextDiff;
+ nextDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ }
+
+ /**
+ * Given two strings, compute a score representing whether the internal
+ * boundary falls on logical boundaries.
+ * Scores range from 6 (best) to 0 (worst).
+ *
+ * @param one First string.
+ * @param two Second string.
+ * @return The score.
+ */
+ private static int diff_cleanupSemanticScore(String one, String two) {
+ if (one.length() == 0 || two.length() == 0) {
+ // Edges are the best.
+ return 6;
+ }
+
+ // Each port of this function behaves slightly differently due to
+ // subtle differences in each language's definition of things like
+ // 'whitespace'. Since this function's purpose is largely cosmetic,
+ // the choice has been made to use each language's native features
+ // rather than force total conformity.
+ char char1 = one.charAt(one.length() - 1);
+ char char2 = two.charAt(0);
+ boolean nonAlphaNumeric1 = !Character.isLetterOrDigit(char1);
+ boolean nonAlphaNumeric2 = !Character.isLetterOrDigit(char2);
+ boolean whitespace1 = nonAlphaNumeric1 && Character.isWhitespace(char1);
+ boolean whitespace2 = nonAlphaNumeric2 && Character.isWhitespace(char2);
+ boolean lineBreak1 = whitespace1
+ && Character.getType(char1) == Character.CONTROL;
+ boolean lineBreak2 = whitespace2
+ && Character.getType(char2) == Character.CONTROL;
+ boolean blankLine1 = lineBreak1 && BLANKLINEEND.matcher(one).find();
+ boolean blankLine2 = lineBreak2 && BLANKLINESTART.matcher(two).find();
+
+ if (blankLine1 || blankLine2) {
+ // Five points for blank lines.
+ return 5;
+ } else if (lineBreak1 || lineBreak2) {
+ // Four points for line breaks.
+ return 4;
+ } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) {
+ // Three points for end of sentences.
+ return 3;
+ } else if (whitespace1 || whitespace2) {
+ // Two points for whitespace.
+ return 2;
+ } else if (nonAlphaNumeric1 || nonAlphaNumeric2) {
+ // One point for non-alphanumeric.
+ return 1;
+ }
+ return 0;
+ }
+
+ // Define some regex patterns for matching boundaries.
+ private static final Pattern BLANKLINEEND
+ = Pattern.compile("\\n\\r?\\n\\Z", Pattern.DOTALL);
+ private static final Pattern BLANKLINESTART
+ = Pattern.compile("\\A\\r?\\n\\r?\\n", Pattern.DOTALL);
+
+// /**
+// * Reduce the number of edits by eliminating operationally trivial equalities.
+// *
+// * @param diffs LinkedList of Diff objects.
+// */
+// public void diff_cleanupEfficiency(LinkedList diffs) {
+// if (diffs.isEmpty()) {
+// return;
+// }
+// boolean changes = false;
+// Deque equalities = new ArrayDeque(); // Double-ended queue of equalities.
+// String lastEquality = null; // Always equal to equalities.peek().text
+// ListIterator pointer = diffs.listIterator();
+// // Is there an insertion operation before the last equality.
+// boolean pre_ins = false;
+// // Is there a deletion operation before the last equality.
+// boolean pre_del = false;
+// // Is there an insertion operation after the last equality.
+// boolean post_ins = false;
+// // Is there a deletion operation after the last equality.
+// boolean post_del = false;
+// Diff thisDiff = pointer.next();
+// Diff safeDiff = thisDiff; // The last Diff that is known to be unsplittable.
+// while (thisDiff != null) {
+// if (thisDiff.operation == Operation.EQUAL) {
+// // Equality found.
+// if (thisDiff.text.length() < Diff_EditCost && (post_ins || post_del)) {
+// // Candidate found.
+// equalities.push(thisDiff);
+// pre_ins = post_ins;
+// pre_del = post_del;
+// lastEquality = thisDiff.text;
+// } else {
+// // Not a candidate, and can never become one.
+// equalities.clear();
+// lastEquality = null;
+// safeDiff = thisDiff;
+// }
+// post_ins = post_del = false;
+// } else {
+// // An insertion or deletion.
+// if (thisDiff.operation == Operation.DELETE) {
+// post_del = true;
+// } else {
+// post_ins = true;
+// }
+// /*
+// * Five types to be split:
+// * A BXYC D
+// * A XC D
+// * A BXC
+// * AXC D
+// * A BXC
+// */
+// if (lastEquality != null
+// && ((pre_ins && pre_del && post_ins && post_del)
+// || ((lastEquality.length() < Diff_EditCost / 2)
+// && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0)
+// + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) {
+// //System.out.println("Splitting: '" + lastEquality + "'");
+// // Walk back to offending equality.
+// while (thisDiff != equalities.peek()) {
+// thisDiff = pointer.previous();
+// }
+// pointer.next();
+//
+// // Replace equality with a delete.
+// pointer.set(new Diff(Operation.DELETE, lastEquality));
+// // Insert a corresponding an insert.
+// pointer.add(thisDiff = new Diff(Operation.INSERT, lastEquality));
+//
+// equalities.pop(); // Throw away the equality we just deleted.
+// lastEquality = null;
+// if (pre_ins && pre_del) {
+// // No changes made which could affect previous entry, keep going.
+// post_ins = post_del = true;
+// equalities.clear();
+// safeDiff = thisDiff;
+// } else {
+// if (!equalities.isEmpty()) {
+// // Throw away the previous equality (it needs to be reevaluated).
+// equalities.pop();
+// }
+// if (equalities.isEmpty()) {
+// // There are no previous questionable equalities,
+// // walk back to the last known safe diff.
+// thisDiff = safeDiff;
+// } else {
+// // There is an equality we can fall back to.
+// thisDiff = equalities.peek();
+// }
+// while (thisDiff != pointer.previous()) {
+// // Intentionally empty loop.
+// }
+// post_ins = post_del = false;
+// }
+//
+// changes = true;
+// }
+// }
+// thisDiff = pointer.hasNext() ? pointer.next() : null;
+// }
+//
+// if (changes) {
+// diff_cleanupMerge(diffs);
+// }
+// }
+
+ /**
+ * Reorder and merge like edit sections. Merge equalities.
+ * Any edit section can move as long as it doesn't cross an equality.
+ *
+ * @param diffs LinkedList of Diff objects.
+ */
+ private static void diff_cleanupMerge(LinkedList diffs) {
+ diffs.add(new Diff(Operation.EQUAL, "")); // Add a dummy entry at the end.
+ ListIterator pointer = diffs.listIterator();
+ int count_delete = 0;
+ int count_insert = 0;
+ String text_delete = "";
+ String text_insert = "";
+ Diff thisDiff = pointer.next();
+ Diff prevEqual = null;
+ int commonlength;
+ while (thisDiff != null) {
+ switch (thisDiff.operation) {
+ case INSERT:
+ count_insert++;
+ text_insert += thisDiff.text;
+ prevEqual = null;
+ break;
+ case DELETE:
+ count_delete++;
+ text_delete += thisDiff.text;
+ prevEqual = null;
+ break;
+ case EQUAL:
+ if (count_delete + count_insert > 1) {
+ boolean both_types = count_delete != 0 && count_insert != 0;
+ // Delete the offending records.
+ pointer.previous(); // Reverse direction.
+ while (count_delete-- > 0) {
+ pointer.previous();
+ pointer.remove();
+ }
+ while (count_insert-- > 0) {
+ pointer.previous();
+ pointer.remove();
+ }
+ if (both_types) {
+ // Factor out any common prefixies.
+ commonlength = diff_commonPrefix(text_insert, text_delete);
+ if (commonlength != 0) {
+ if (pointer.hasPrevious()) {
+ thisDiff = pointer.previous();
+ assert thisDiff.operation == Operation.EQUAL
+ : "Previous diff should have been an equality.";
+ thisDiff.text += text_insert.substring(0, commonlength);
+ pointer.next();
+ } else {
+ pointer.add(new Diff(Operation.EQUAL,
+ text_insert.substring(0, commonlength)));
+ }
+ text_insert = text_insert.substring(commonlength);
+ text_delete = text_delete.substring(commonlength);
+ }
+ // Factor out any common suffixies.
+ commonlength = diff_commonSuffix(text_insert, text_delete);
+ if (commonlength != 0) {
+ thisDiff = pointer.next();
+ thisDiff.text = text_insert.substring(text_insert.length()
+ - commonlength) + thisDiff.text;
+ text_insert = text_insert.substring(0, text_insert.length()
+ - commonlength);
+ text_delete = text_delete.substring(0, text_delete.length()
+ - commonlength);
+ pointer.previous();
+ }
+ }
+ // Insert the merged records.
+ if (text_delete.length() != 0) {
+ pointer.add(new Diff(Operation.DELETE, text_delete));
+ }
+ if (text_insert.length() != 0) {
+ pointer.add(new Diff(Operation.INSERT, text_insert));
+ }
+ // Step forward to the equality.
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ } else if (prevEqual != null) {
+ // Merge this equality with the previous one.
+ prevEqual.text += thisDiff.text;
+ pointer.remove();
+ thisDiff = pointer.previous();
+ pointer.next(); // Forward direction
+ }
+ count_insert = 0;
+ count_delete = 0;
+ text_delete = "";
+ text_insert = "";
+ prevEqual = thisDiff;
+ break;
+ }
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ if (diffs.getLast().text.length() == 0) {
+ diffs.removeLast(); // Remove the dummy entry at the end.
+ }
+
+ /*
+ * Second pass: look for single edits surrounded on both sides by equalities
+ * which can be shifted sideways to eliminate an equality.
+ * e.g: ABA C -> AB AC
+ */
+ boolean changes = false;
+ // Create a new iterator at the start.
+ // (As opposed to walking the current one back.)
+ pointer = diffs.listIterator();
+ Diff prevDiff = pointer.hasNext() ? pointer.next() : null;
+ thisDiff = pointer.hasNext() ? pointer.next() : null;
+ Diff nextDiff = pointer.hasNext() ? pointer.next() : null;
+ // Intentionally ignore the first and last element (don't need checking).
+ while (nextDiff != null) {
+ if (prevDiff.operation == Operation.EQUAL &&
+ nextDiff.operation == Operation.EQUAL) {
+ // This is a single edit surrounded by equalities.
+ if (thisDiff.text.endsWith(prevDiff.text)) {
+ // Shift the edit over the previous equality.
+ thisDiff.text = prevDiff.text
+ + thisDiff.text.substring(0, thisDiff.text.length()
+ - prevDiff.text.length());
+ nextDiff.text = prevDiff.text + nextDiff.text;
+ pointer.previous(); // Walk past nextDiff.
+ pointer.previous(); // Walk past thisDiff.
+ pointer.previous(); // Walk past prevDiff.
+ pointer.remove(); // Delete prevDiff.
+ pointer.next(); // Walk past thisDiff.
+ thisDiff = pointer.next(); // Walk past nextDiff.
+ nextDiff = pointer.hasNext() ? pointer.next() : null;
+ changes = true;
+ } else if (thisDiff.text.startsWith(nextDiff.text)) {
+ // Shift the edit over the next equality.
+ prevDiff.text += nextDiff.text;
+ thisDiff.text = thisDiff.text.substring(nextDiff.text.length())
+ + nextDiff.text;
+ pointer.remove(); // Delete nextDiff.
+ nextDiff = pointer.hasNext() ? pointer.next() : null;
+ changes = true;
+ }
+ }
+ prevDiff = thisDiff;
+ thisDiff = nextDiff;
+ nextDiff = pointer.hasNext() ? pointer.next() : null;
+ }
+ // If shifts were made, the diff needs reordering and another shift sweep.
+ if (changes) {
+ diff_cleanupMerge(diffs);
+ }
+ }
+
+// /**
+// * loc is a location in text1, compute and return the equivalent location in
+// * text2.
+// * e.g. "The cat" vs "The big cat", 1->1, 5->8
+// * @param diffs List of Diff objects.
+// * @param loc Location within text1.
+// * @return Location within text2.
+// */
+// public int diff_xIndex(List diffs, int loc) {
+// int chars1 = 0;
+// int chars2 = 0;
+// int last_chars1 = 0;
+// int last_chars2 = 0;
+// Diff lastDiff = null;
+// for (Diff aDiff : diffs) {
+// if (aDiff.operation != Operation.INSERT) {
+// // Equality or deletion.
+// chars1 += aDiff.text.length();
+// }
+// if (aDiff.operation != Operation.DELETE) {
+// // Equality or insertion.
+// chars2 += aDiff.text.length();
+// }
+// if (chars1 > loc) {
+// // Overshot the location.
+// lastDiff = aDiff;
+// break;
+// }
+// last_chars1 = chars1;
+// last_chars2 = chars2;
+// }
+// if (lastDiff != null && lastDiff.operation == Operation.DELETE) {
+// // The location was deleted.
+// return last_chars2;
+// }
+// // Add the remaining character length.
+// return last_chars2 + (loc - last_chars1);
+//}
+
+// /**
+// * Convert a Diff list into a pretty HTML report.
+// * @param diffs List of Diff objects.
+// * @return HTML representation.
+// */
+// public String diff_prettyHtml(List diffs) {
+// StringBuilder html = new StringBuilder();
+// for (Diff aDiff : diffs) {
+// String text = aDiff.text.replace("&", "&").replace("<", "<")
+// .replace(">", ">").replace("\n", "¶ ");
+// switch (aDiff.operation) {
+// case INSERT:
+// html.append("").append(text)
+// .append(" ");
+// break;
+// case DELETE:
+// html.append("").append(text)
+// .append("");
+// break;
+// case EQUAL:
+// html.append("").append(text).append(" ");
+// break;
+// }
+// }
+// return html.toString();
+// }
+
+// /**
+// * Compute and return the source text (all equalities and deletions).
+// * @param diffs List of Diff objects.
+// * @return Source text.
+// */
+// public String diff_text1(List diffs) {
+// StringBuilder text = new StringBuilder();
+// for (Diff aDiff : diffs) {
+// if (aDiff.operation != Operation.INSERT) {
+// text.append(aDiff.text);
+// }
+// }
+// return text.toString();
+// }
+
+// /**
+// * Compute and return the destination text (all equalities and insertions).
+// * @param diffs List of Diff objects.
+// * @return Destination text.
+// */
+// public String diff_text2(List diffs) {
+// StringBuilder text = new StringBuilder();
+// for (Diff aDiff : diffs) {
+// if (aDiff.operation != Operation.DELETE) {
+// text.append(aDiff.text);
+// }
+// }
+// return text.toString();
+// }
+
+// /**
+// * Compute the Levenshtein distance; the number of inserted, deleted or
+// * substituted characters.
+// * @param diffs List of Diff objects.
+// * @return Number of changes.
+// */
+// public int diff_levenshtein(List diffs) {
+// int levenshtein = 0;
+// int insertions = 0;
+// int deletions = 0;
+// for (Diff aDiff : diffs) {
+// switch (aDiff.operation) {
+// case INSERT:
+// insertions += aDiff.text.length();
+// break;
+// case DELETE:
+// deletions += aDiff.text.length();
+// break;
+// case EQUAL:
+// // A deletion and an insertion is one substitution.
+// levenshtein += Math.max(insertions, deletions);
+// insertions = 0;
+// deletions = 0;
+// break;
+// }
+// }
+// levenshtein += Math.max(insertions, deletions);
+// return levenshtein;
+// }
+
+// /**
+// * Crush the diff into an encoded string which describes the operations
+// * required to transform text1 into text2.
+// * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'.
+// * Operations are tab-separated. Inserted text is escaped using %xx notation.
+// * @param diffs List of Diff objects.
+// * @return Delta text.
+// */
+// public String diff_toDelta(List diffs) {
+// StringBuilder text = new StringBuilder();
+// for (Diff aDiff : diffs) {
+// switch (aDiff.operation) {
+// case INSERT:
+// try {
+// text.append("+").append(URLEncoder.encode(aDiff.text, "UTF-8")
+// .replace('+', ' ')).append("\t");
+// } catch (UnsupportedEncodingException e) {
+// // Not likely on modern system.
+// throw new Error("This system does not support UTF-8.", e);
+// }
+// break;
+// case DELETE:
+// text.append("-").append(aDiff.text.length()).append("\t");
+// break;
+// case EQUAL:
+// text.append("=").append(aDiff.text.length()).append("\t");
+// break;
+// }
+// }
+// String delta = text.toString();
+// if (delta.length() != 0) {
+// // Strip off trailing tab character.
+// delta = delta.substring(0, delta.length() - 1);
+// delta = unescapeForEncodeUriCompatability(delta);
+// }
+// return delta;
+// }
+
+// /**
+// * Given the original text1, and an encoded string which describes the
+// * operations required to transform text1 into text2, compute the full diff.
+// * @param text1 Source string for the diff.
+// * @param delta Delta text.
+// * @return Array of Diff objects or null if invalid.
+// * @throws IllegalArgumentException If invalid input.
+// */
+// public LinkedList diff_fromDelta(String text1, String delta)
+// throws IllegalArgumentException {
+// LinkedList diffs = new LinkedList();
+// int pointer = 0; // Cursor in text1
+// String[] tokens = delta.split("\t");
+// for (String token : tokens) {
+// if (token.length() == 0) {
+// // Blank tokens are ok (from a trailing \t).
+// continue;
+// }
+// // Each token begins with a one character parameter which specifies the
+// // operation of this token (delete, insert, equality).
+// String param = token.substring(1);
+// switch (token.charAt(0)) {
+// case '+':
+// // decode would change all "+" to " "
+// param = param.replace("+", "%2B");
+// try {
+// param = URLDecoder.decode(param, "UTF-8");
+// } catch (UnsupportedEncodingException e) {
+// // Not likely on modern system.
+// throw new Error("This system does not support UTF-8.", e);
+// } catch (IllegalArgumentException e) {
+// // Malformed URI sequence.
+// throw new IllegalArgumentException(
+// "Illegal escape in diff_fromDelta: " + param, e);
+// }
+// diffs.add(new Diff(Operation.INSERT, param));
+// break;
+// case '-':
+// // Fall through.
+// case '=':
+// int n;
+// try {
+// n = Integer.parseInt(param);
+// } catch (NumberFormatException e) {
+// throw new IllegalArgumentException(
+// "Invalid number in diff_fromDelta: " + param, e);
+// }
+// if (n < 0) {
+// throw new IllegalArgumentException(
+// "Negative number in diff_fromDelta: " + param);
+// }
+// String text;
+// try {
+// text = text1.substring(pointer, pointer += n);
+// } catch (StringIndexOutOfBoundsException e) {
+// throw new IllegalArgumentException("Delta length (" + pointer
+// + ") larger than source text length (" + text1.length()
+// + ").", e);
+// }
+// if (token.charAt(0) == '=') {
+// diffs.add(new Diff(Operation.EQUAL, text));
+// } else {
+// diffs.add(new Diff(Operation.DELETE, text));
+// }
+// break;
+// default:
+// // Anything else is an error.
+// throw new IllegalArgumentException(
+// "Invalid diff operation in diff_fromDelta: " + token.charAt(0));
+// }
+// }
+// if (pointer != text1.length()) {
+// throw new IllegalArgumentException("Delta length (" + pointer
+// + ") smaller than source text length (" + text1.length() + ").");
+// }
+// return diffs;
+// }
+
+
+ // MATCH FUNCTIONS
+
+
+// /**
+// * Locate the best instance of 'pattern' in 'text' near 'loc'.
+// * Returns -1 if no match found.
+// * @param text The text to search.
+// * @param pattern The pattern to search for.
+// * @param loc The location to search around.
+// * @return Best match index or -1.
+// */
+// public int match_main(String text, String pattern, int loc) {
+// // Check for null inputs.
+// if (text == null || pattern == null) {
+// throw new IllegalArgumentException("Null inputs. (match_main)");
+// }
+//
+// loc = Math.max(0, Math.min(loc, text.length()));
+// if (text.equals(pattern)) {
+// // Shortcut (potentially not guaranteed by the algorithm)
+// return 0;
+// } else if (text.length() == 0) {
+// // Nothing to match.
+// return -1;
+// } else if (loc + pattern.length() <= text.length()
+// && text.substring(loc, loc + pattern.length()).equals(pattern)) {
+// // Perfect match at the perfect spot! (Includes case of null pattern)
+// return loc;
+// } else {
+// // Do a fuzzy compare.
+// return match_bitap(text, pattern, loc);
+// }
+// }
+
+// /**
+// * Locate the best instance of 'pattern' in 'text' near 'loc' using the
+// * Bitap algorithm. Returns -1 if no match found.
+// *
+// * @param text The text to search.
+// * @param pattern The pattern to search for.
+// * @param loc The location to search around.
+// * @return Best match index or -1.
+// */
+// protected int match_bitap(String text, String pattern, int loc) {
+// assert (Match_MaxBits == 0 || pattern.length() <= Match_MaxBits)
+// : "Pattern too long for this application.";
+//
+// // Initialise the alphabet.
+// Map s = match_alphabet(pattern);
+//
+// // Highest score beyond which we give up.
+// double score_threshold = Match_Threshold;
+// // Is there a nearby exact match? (speedup)
+// int best_loc = text.indexOf(pattern, loc);
+// if (best_loc != -1) {
+// score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern),
+// score_threshold);
+// // What about in the other direction? (speedup)
+// best_loc = text.lastIndexOf(pattern, loc + pattern.length());
+// if (best_loc != -1) {
+// score_threshold = Math.min(match_bitapScore(0, best_loc, loc, pattern),
+// score_threshold);
+// }
+// }
+//
+// // Initialise the bit arrays.
+// int matchmask = 1 << (pattern.length() - 1);
+// best_loc = -1;
+//
+// int bin_min, bin_mid;
+// int bin_max = pattern.length() + text.length();
+// // Empty initialization added to appease Java compiler.
+// int[] last_rd = new int[0];
+// for (int d = 0; d < pattern.length(); d++) {
+// // Scan for the best match; each iteration allows for one more error.
+// // Run a binary search to determine how far from 'loc' we can stray at
+// // this error level.
+// bin_min = 0;
+// bin_mid = bin_max;
+// while (bin_min < bin_mid) {
+// if (match_bitapScore(d, loc + bin_mid, loc, pattern)
+// <= score_threshold) {
+// bin_min = bin_mid;
+// } else {
+// bin_max = bin_mid;
+// }
+// bin_mid = (bin_max - bin_min) / 2 + bin_min;
+// }
+// // Use the result from this iteration as the maximum for the next.
+// bin_max = bin_mid;
+// int start = Math.max(1, loc - bin_mid + 1);
+// int finish = Math.min(loc + bin_mid, text.length()) + pattern.length();
+//
+// int[] rd = new int[finish + 2];
+// rd[finish + 1] = (1 << d) - 1;
+// for (int j = finish; j >= start; j--) {
+// int charMatch;
+// if (text.length() <= j - 1 || !s.containsKey(text.charAt(j - 1))) {
+// // Out of range.
+// charMatch = 0;
+// } else {
+// charMatch = s.get(text.charAt(j - 1));
+// }
+// if (d == 0) {
+// // First pass: exact match.
+// rd[j] = ((rd[j + 1] << 1) | 1) & charMatch;
+// } else {
+// // Subsequent passes: fuzzy match.
+// rd[j] = (((rd[j + 1] << 1) | 1) & charMatch)
+// | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1];
+// }
+// if ((rd[j] & matchmask) != 0) {
+// double score = match_bitapScore(d, j - 1, loc, pattern);
+// // This match will almost certainly be better than any existing
+// // match. But check anyway.
+// if (score <= score_threshold) {
+// // Told you so.
+// score_threshold = score;
+// best_loc = j - 1;
+// if (best_loc > loc) {
+// // When passing loc, don't exceed our current distance from loc.
+// start = Math.max(1, 2 * loc - best_loc);
+// } else {
+// // Already passed loc, downhill from here on in.
+// break;
+// }
+// }
+// }
+// }
+// if (match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) {
+// // No hope for a (better) match at greater error levels.
+// break;
+// }
+// last_rd = rd;
+// }
+// return best_loc;
+// }
+
+// /**
+// * Compute and return the score for a match with e errors and x location.
+// *
+// * @param e Number of errors in match.
+// * @param x Location of match.
+// * @param loc Expected location of match.
+// * @param pattern Pattern being sought.
+// * @return Overall score for match (0.0 = good, 1.0 = bad).
+// */
+// private double match_bitapScore(int e, int x, int loc, String pattern) {
+// float accuracy = (float) e / pattern.length();
+// int proximity = Math.abs(loc - x);
+// if (Match_Distance == 0) {
+// // Dodge divide by zero error.
+// return proximity == 0 ? accuracy : 1.0;
+// }
+// return accuracy + (proximity / (float) Match_Distance);
+// }
+
+// /**
+// * Initialise the alphabet for the Bitap algorithm.
+// *
+// * @param pattern The text to encode.
+// * @return Hash of character locations.
+// */
+// protected Map match_alphabet(String pattern) {
+// Map s = new HashMap();
+// char[] char_pattern = pattern.toCharArray();
+// for (char c : char_pattern) {
+// s.put(c, 0);
+// }
+// int i = 0;
+// for (char c : char_pattern) {
+// s.put(c, s.get(c) | (1 << (pattern.length() - i - 1)));
+// i++;
+// }
+// return s;
+// }
+
+
+ // PATCH FUNCTIONS
+
+
+// /**
+// * Increase the context until it is unique,
+// * but don't let the pattern expand beyond Match_MaxBits.
+// *
+// * @param patch The patch to grow.
+// * @param text Source text.
+// */
+// protected void patch_addContext(Patch patch, String text) {
+// if (text.length() == 0) {
+// return;
+// }
+// String pattern = text.substring(patch.start2, patch.start2 + patch.length1);
+// int padding = 0;
+//
+// // Look for the first and last matches of pattern in text. If two different
+// // matches are found, increase the pattern length.
+// while (text.indexOf(pattern) != text.lastIndexOf(pattern)
+// && pattern.length() < Match_MaxBits - Patch_Margin - Patch_Margin) {
+// padding += Patch_Margin;
+// pattern = text.substring(Math.max(0, patch.start2 - padding),
+// Math.min(text.length(), patch.start2 + patch.length1 + padding));
+// }
+// // Add one chunk for good luck.
+// padding += Patch_Margin;
+//
+// // Add the prefix.
+// String prefix = text.substring(Math.max(0, patch.start2 - padding),
+// patch.start2);
+// if (prefix.length() != 0) {
+// patch.diffs.addFirst(new Diff(Operation.EQUAL, prefix));
+// }
+// // Add the suffix.
+// String suffix = text.substring(patch.start2 + patch.length1,
+// Math.min(text.length(), patch.start2 + patch.length1 + padding));
+// if (suffix.length() != 0) {
+// patch.diffs.addLast(new Diff(Operation.EQUAL, suffix));
+// }
+//
+// // Roll back the start points.
+// patch.start1 -= prefix.length();
+// patch.start2 -= prefix.length();
+// // Extend the lengths.
+// patch.length1 += prefix.length() + suffix.length();
+// patch.length2 += prefix.length() + suffix.length();
+// }
+
+// /**
+// * Compute a list of patches to turn text1 into text2.
+// * A set of diffs will be computed.
+// * @param text1 Old text.
+// * @param text2 New text.
+// * @return LinkedList of Patch objects.
+// */
+// public LinkedList patch_make(String text1, String text2) {
+// if (text1 == null || text2 == null) {
+// throw new IllegalArgumentException("Null inputs. (patch_make)");
+// }
+// // No diffs provided, compute our own.
+// LinkedList diffs = diff_main(text1, text2, true);
+// if (diffs.size() > 2) {
+// diff_cleanupSemantic(diffs);
+// diff_cleanupEfficiency(diffs);
+// }
+// return patch_make(text1, diffs);
+// }
+
+// /**
+// * Compute a list of patches to turn text1 into text2.
+// * text1 will be derived from the provided diffs.
+// * @param diffs Array of Diff objects for text1 to text2.
+// * @return LinkedList of Patch objects.
+// */
+// public LinkedList patch_make(LinkedList diffs) {
+// if (diffs == null) {
+// throw new IllegalArgumentException("Null inputs. (patch_make)");
+// }
+// // No origin string provided, compute our own.
+// String text1 = diff_text1(diffs);
+// return patch_make(text1, diffs);
+// }
+
+// /**
+// * Compute a list of patches to turn text1 into text2.
+// * text2 is ignored, diffs are the delta between text1 and text2.
+// *
+// * @param text1 Old text
+// * @param text2 Ignored.
+// * @param diffs Array of Diff objects for text1 to text2.
+// * @return LinkedList of Patch objects.
+// * @deprecated Prefer patch_make(String text1, LinkedList diffs).
+// */
+// @Deprecated
+// public LinkedList patch_make(String text1, String text2,
+// LinkedList diffs) {
+// return patch_make(text1, diffs);
+// }
+
+// /**
+// * Compute a list of patches to turn text1 into text2.
+// * text2 is not provided, diffs are the delta between text1 and text2.
+// *
+// * @param text1 Old text.
+// * @param diffs Array of Diff objects for text1 to text2.
+// * @return LinkedList of Patch objects.
+// */
+// public LinkedList patch_make(String text1, LinkedList diffs) {
+// if (text1 == null || diffs == null) {
+// throw new IllegalArgumentException("Null inputs. (patch_make)");
+// }
+//
+// LinkedList patches = new LinkedList();
+// if (diffs.isEmpty()) {
+// return patches; // Get rid of the null case.
+// }
+// Patch patch = new Patch();
+// int char_count1 = 0; // Number of characters into the text1 string.
+// int char_count2 = 0; // Number of characters into the text2 string.
+// // Start with text1 (prepatch_text) and apply the diffs until we arrive at
+// // text2 (postpatch_text). We recreate the patches one by one to determine
+// // context info.
+// String prepatch_text = text1;
+// String postpatch_text = text1;
+// for (Diff aDiff : diffs) {
+// if (patch.diffs.isEmpty() && aDiff.operation != Operation.EQUAL) {
+// // A new patch starts here.
+// patch.start1 = char_count1;
+// patch.start2 = char_count2;
+// }
+//
+// switch (aDiff.operation) {
+// case INSERT:
+// patch.diffs.add(aDiff);
+// patch.length2 += aDiff.text.length();
+// postpatch_text = postpatch_text.substring(0, char_count2)
+// + aDiff.text + postpatch_text.substring(char_count2);
+// break;
+// case DELETE:
+// patch.length1 += aDiff.text.length();
+// patch.diffs.add(aDiff);
+// postpatch_text = postpatch_text.substring(0, char_count2)
+// + postpatch_text.substring(char_count2 + aDiff.text.length());
+// break;
+// case EQUAL:
+// if (aDiff.text.length() <= 2 * Patch_Margin
+// && !patch.diffs.isEmpty() && aDiff != diffs.getLast()) {
+// // Small equality inside a patch.
+// patch.diffs.add(aDiff);
+// patch.length1 += aDiff.text.length();
+// patch.length2 += aDiff.text.length();
+// }
+//
+// if (aDiff.text.length() >= 2 * Patch_Margin && !patch.diffs.isEmpty()) {
+// // Time for a new patch.
+// if (!patch.diffs.isEmpty()) {
+// patch_addContext(patch, prepatch_text);
+// patches.add(patch);
+// patch = new Patch();
+// // Unlike Unidiff, our patch lists have a rolling context.
+// // https://github.com/google/diff-match-patch/wiki/Unidiff
+// // Update prepatch text & pos to reflect the application of the
+// // just completed patch.
+// prepatch_text = postpatch_text;
+// char_count1 = char_count2;
+// }
+// }
+// break;
+// }
+//
+// // Update the current character count.
+// if (aDiff.operation != Operation.INSERT) {
+// char_count1 += aDiff.text.length();
+// }
+// if (aDiff.operation != Operation.DELETE) {
+// char_count2 += aDiff.text.length();
+// }
+// }
+// // Pick up the leftover patch if not empty.
+// if (!patch.diffs.isEmpty()) {
+// patch_addContext(patch, prepatch_text);
+// patches.add(patch);
+// }
+//
+// return patches;
+// }
+
+// /**
+// * Given an array of patches, return another array that is identical.
+// * @param patches Array of Patch objects.
+// * @return Array of Patch objects.
+// */
+// public LinkedList patch_deepCopy(LinkedList patches) {
+// LinkedList patchesCopy = new LinkedList();
+// for (Patch aPatch : patches) {
+// Patch patchCopy = new Patch();
+// for (Diff aDiff : aPatch.diffs) {
+// Diff diffCopy = new Diff(aDiff.operation, aDiff.text);
+// patchCopy.diffs.add(diffCopy);
+// }
+// patchCopy.start1 = aPatch.start1;
+// patchCopy.start2 = aPatch.start2;
+// patchCopy.length1 = aPatch.length1;
+// patchCopy.length2 = aPatch.length2;
+// patchesCopy.add(patchCopy);
+// }
+// return patchesCopy;
+// }
+
+// /**
+// * Merge a set of patches onto the text. Return a patched text, as well
+// * as an array of true/false values indicating which patches were applied.
+// * @param patches Array of Patch objects
+// * @param text Old text.
+// * @return Two element Object array, containing the new text and an array of
+// * boolean values.
+// */
+// public Object[] patch_apply(LinkedList patches, String text) {
+// if (patches.isEmpty()) {
+// return new Object[]{text, new boolean[0]};
+// }
+//
+// // Deep copy the patches so that no changes are made to originals.
+// patches = patch_deepCopy(patches);
+//
+// String nullPadding = patch_addPadding(patches);
+// text = nullPadding + text + nullPadding;
+// patch_splitMax(patches);
+//
+// int x = 0;
+// // delta keeps track of the offset between the expected and actual location
+// // of the previous patch. If there are patches expected at positions 10 and
+// // 20, but the first patch was found at 12, delta is 2 and the second patch
+// // has an effective expected position of 22.
+// int delta = 0;
+// boolean[] results = new boolean[patches.size()];
+// for (Patch aPatch : patches) {
+// int expected_loc = aPatch.start2 + delta;
+// String text1 = diff_text1(aPatch.diffs);
+// int start_loc;
+// int end_loc = -1;
+// if (text1.length() > this.Match_MaxBits) {
+// // patch_splitMax will only provide an oversized pattern in the case of
+// // a monster delete.
+// start_loc = match_main(text,
+// text1.substring(0, this.Match_MaxBits), expected_loc);
+// if (start_loc != -1) {
+// end_loc = match_main(text,
+// text1.substring(text1.length() - this.Match_MaxBits),
+// expected_loc + text1.length() - this.Match_MaxBits);
+// if (end_loc == -1 || start_loc >= end_loc) {
+// // Can't find valid trailing context. Drop this patch.
+// start_loc = -1;
+// }
+// }
+// } else {
+// start_loc = match_main(text, text1, expected_loc);
+// }
+// if (start_loc == -1) {
+// // No match found. :(
+// results[x] = false;
+// // Subtract the delta for this failed patch from subsequent patches.
+// delta -= aPatch.length2 - aPatch.length1;
+// } else {
+// // Found a match. :)
+// results[x] = true;
+// delta = start_loc - expected_loc;
+// String text2;
+// if (end_loc == -1) {
+// text2 = text.substring(start_loc,
+// Math.min(start_loc + text1.length(), text.length()));
+// } else {
+// text2 = text.substring(start_loc,
+// Math.min(end_loc + this.Match_MaxBits, text.length()));
+// }
+// if (text1.equals(text2)) {
+// // Perfect match, just shove the replacement text in.
+// text = text.substring(0, start_loc) + diff_text2(aPatch.diffs)
+// + text.substring(start_loc + text1.length());
+// } else {
+// // Imperfect match. Run a diff to get a framework of equivalent
+// // indices.
+// LinkedList diffs = diff_main(text1, text2, false);
+// if (text1.length() > this.Match_MaxBits
+// && diff_levenshtein(diffs) / (float) text1.length()
+// > this.Patch_DeleteThreshold) {
+// // The end points match, but the content is unacceptably bad.
+// results[x] = false;
+// } else {
+// diff_cleanupSemanticLossless(diffs);
+// int index1 = 0;
+// for (Diff aDiff : aPatch.diffs) {
+// if (aDiff.operation != Operation.EQUAL) {
+// int index2 = diff_xIndex(diffs, index1);
+// if (aDiff.operation == Operation.INSERT) {
+// // Insertion
+// text = text.substring(0, start_loc + index2) + aDiff.text
+// + text.substring(start_loc + index2);
+// } else if (aDiff.operation == Operation.DELETE) {
+// // Deletion
+// text = text.substring(0, start_loc + index2)
+// + text.substring(start_loc + diff_xIndex(diffs,
+// index1 + aDiff.text.length()));
+// }
+// }
+// if (aDiff.operation != Operation.DELETE) {
+// index1 += aDiff.text.length();
+// }
+// }
+// }
+// }
+// }
+// x++;
+// }
+// // Strip the padding off.
+// text = text.substring(nullPadding.length(), text.length()
+// - nullPadding.length());
+// return new Object[]{text, results};
+// }
+
+// /**
+// * Add some padding on text start and end so that edges can match something.
+// * Intended to be called only from within patch_apply.
+// * @param patches Array of Patch objects.
+// * @return The padding string added to each side.
+// */
+// public String patch_addPadding(LinkedList patches) {
+// short paddingLength = this.Patch_Margin;
+// String nullPadding = "";
+// for (short x = 1; x <= paddingLength; x++) {
+// nullPadding += String.valueOf((char) x);
+// }
+//
+// // Bump all the patches forward.
+// for (Patch aPatch : patches) {
+// aPatch.start1 += paddingLength;
+// aPatch.start2 += paddingLength;
+// }
+//
+// // Add some padding on start of first diff.
+// Patch patch = patches.getFirst();
+// LinkedList diffs = patch.diffs;
+// if (diffs.isEmpty() || diffs.getFirst().operation != Operation.EQUAL) {
+// // Add nullPadding equality.
+// diffs.addFirst(new Diff(Operation.EQUAL, nullPadding));
+// patch.start1 -= paddingLength; // Should be 0.
+// patch.start2 -= paddingLength; // Should be 0.
+// patch.length1 += paddingLength;
+// patch.length2 += paddingLength;
+// } else if (paddingLength > diffs.getFirst().text.length()) {
+// // Grow first equality.
+// Diff firstDiff = diffs.getFirst();
+// int extraLength = paddingLength - firstDiff.text.length();
+// firstDiff.text = nullPadding.substring(firstDiff.text.length())
+// + firstDiff.text;
+// patch.start1 -= extraLength;
+// patch.start2 -= extraLength;
+// patch.length1 += extraLength;
+// patch.length2 += extraLength;
+// }
+//
+// // Add some padding on end of last diff.
+// patch = patches.getLast();
+// diffs = patch.diffs;
+// if (diffs.isEmpty() || diffs.getLast().operation != Operation.EQUAL) {
+// // Add nullPadding equality.
+// diffs.addLast(new Diff(Operation.EQUAL, nullPadding));
+// patch.length1 += paddingLength;
+// patch.length2 += paddingLength;
+// } else if (paddingLength > diffs.getLast().text.length()) {
+// // Grow last equality.
+// Diff lastDiff = diffs.getLast();
+// int extraLength = paddingLength - lastDiff.text.length();
+// lastDiff.text += nullPadding.substring(0, extraLength);
+// patch.length1 += extraLength;
+// patch.length2 += extraLength;
+// }
+//
+// return nullPadding;
+// }
+
+// /**
+// * Look through the patches and break up any which are longer than the
+// * maximum limit of the match algorithm.
+// * Intended to be called only from within patch_apply.
+// * @param patches LinkedList of Patch objects.
+// */
+// public void patch_splitMax(LinkedList patches) {
+// short patch_size = Match_MaxBits;
+// String precontext, postcontext;
+// Patch patch;
+// int start1, start2;
+// boolean empty;
+// Operation diff_type;
+// String diff_text;
+// ListIterator pointer = patches.listIterator();
+// Patch bigpatch = pointer.hasNext() ? pointer.next() : null;
+// while (bigpatch != null) {
+// if (bigpatch.length1 <= Match_MaxBits) {
+// bigpatch = pointer.hasNext() ? pointer.next() : null;
+// continue;
+// }
+// // Remove the big old patch.
+// pointer.remove();
+// start1 = bigpatch.start1;
+// start2 = bigpatch.start2;
+// precontext = "";
+// while (!bigpatch.diffs.isEmpty()) {
+// // Create one of several smaller patches.
+// patch = new Patch();
+// empty = true;
+// patch.start1 = start1 - precontext.length();
+// patch.start2 = start2 - precontext.length();
+// if (precontext.length() != 0) {
+// patch.length1 = patch.length2 = precontext.length();
+// patch.diffs.add(new Diff(Operation.EQUAL, precontext));
+// }
+// while (!bigpatch.diffs.isEmpty()
+// && patch.length1 < patch_size - Patch_Margin) {
+// diff_type = bigpatch.diffs.getFirst().operation;
+// diff_text = bigpatch.diffs.getFirst().text;
+// if (diff_type == Operation.INSERT) {
+// // Insertions are harmless.
+// patch.length2 += diff_text.length();
+// start2 += diff_text.length();
+// patch.diffs.addLast(bigpatch.diffs.removeFirst());
+// empty = false;
+// } else if (diff_type == Operation.DELETE && patch.diffs.size() == 1
+// && patch.diffs.getFirst().operation == Operation.EQUAL
+// && diff_text.length() > 2 * patch_size) {
+// // This is a large deletion. Let it pass in one chunk.
+// patch.length1 += diff_text.length();
+// start1 += diff_text.length();
+// empty = false;
+// patch.diffs.add(new Diff(diff_type, diff_text));
+// bigpatch.diffs.removeFirst();
+// } else {
+// // Deletion or equality. Only take as much as we can stomach.
+// diff_text = diff_text.substring(0, Math.min(diff_text.length(),
+// patch_size - patch.length1 - Patch_Margin));
+// patch.length1 += diff_text.length();
+// start1 += diff_text.length();
+// if (diff_type == Operation.EQUAL) {
+// patch.length2 += diff_text.length();
+// start2 += diff_text.length();
+// } else {
+// empty = false;
+// }
+// patch.diffs.add(new Diff(diff_type, diff_text));
+// if (diff_text.equals(bigpatch.diffs.getFirst().text)) {
+// bigpatch.diffs.removeFirst();
+// } else {
+// bigpatch.diffs.getFirst().text = bigpatch.diffs.getFirst().text
+// .substring(diff_text.length());
+// }
+// }
+// }
+// // Compute the head context for the next patch.
+// precontext = diff_text2(patch.diffs);
+// precontext = precontext.substring(Math.max(0, precontext.length()
+// - Patch_Margin));
+// // Append the end context for this patch.
+// if (diff_text1(bigpatch.diffs).length() > Patch_Margin) {
+// postcontext = diff_text1(bigpatch.diffs).substring(0, Patch_Margin);
+// } else {
+// postcontext = diff_text1(bigpatch.diffs);
+// }
+// if (postcontext.length() != 0) {
+// patch.length1 += postcontext.length();
+// patch.length2 += postcontext.length();
+// if (!patch.diffs.isEmpty()
+// && patch.diffs.getLast().operation == Operation.EQUAL) {
+// patch.diffs.getLast().text += postcontext;
+// } else {
+// patch.diffs.add(new Diff(Operation.EQUAL, postcontext));
+// }
+// }
+// if (!empty) {
+// pointer.add(patch);
+// }
+// }
+// bigpatch = pointer.hasNext() ? pointer.next() : null;
+// }
+// }
+
+// /**
+// * Take a list of patches and return a textual representation.
+// * @param patches List of Patch objects.
+// * @return Text representation of patches.
+// */
+// public String patch_toText(List patches) {
+// StringBuilder text = new StringBuilder();
+// for (Patch aPatch : patches) {
+// text.append(aPatch);
+// }
+// return text.toString();
+// }
+
+// /**
+// * Parse a textual representation of patches and return a List of Patch
+// * objects.
+// * @param textline Text representation of patches.
+// * @return List of Patch objects.
+// * @throws IllegalArgumentException If invalid input.
+// */
+// public List patch_fromText(String textline)
+// throws IllegalArgumentException {
+// List patches = new LinkedList();
+// if (textline.length() == 0) {
+// return patches;
+// }
+// List textList = Arrays.asList(textline.split("\n"));
+// LinkedList text = new LinkedList(textList);
+// Patch patch;
+// Pattern patchHeader
+// = Pattern.compile("^@@ -(\\d+),?(\\d*) \\+(\\d+),?(\\d*) @@$");
+// Matcher m;
+// char sign;
+// String line;
+// while (!text.isEmpty()) {
+// m = patchHeader.matcher(text.getFirst());
+// if (!m.matches()) {
+// throw new IllegalArgumentException(
+// "Invalid patch string: " + text.getFirst());
+// }
+// patch = new Patch();
+// patches.add(patch);
+// patch.start1 = Integer.parseInt(m.group(1));
+// if (m.group(2).length() == 0) {
+// patch.start1--;
+// patch.length1 = 1;
+// } else if (m.group(2).equals("0")) {
+// patch.length1 = 0;
+// } else {
+// patch.start1--;
+// patch.length1 = Integer.parseInt(m.group(2));
+// }
+//
+// patch.start2 = Integer.parseInt(m.group(3));
+// if (m.group(4).length() == 0) {
+// patch.start2--;
+// patch.length2 = 1;
+// } else if (m.group(4).equals("0")) {
+// patch.length2 = 0;
+// } else {
+// patch.start2--;
+// patch.length2 = Integer.parseInt(m.group(4));
+// }
+// text.removeFirst();
+//
+// while (!text.isEmpty()) {
+// try {
+// sign = text.getFirst().charAt(0);
+// } catch (IndexOutOfBoundsException e) {
+// // Blank line? Whatever.
+// text.removeFirst();
+// continue;
+// }
+// line = text.getFirst().substring(1);
+// line = line.replace("+", "%2B"); // decode would change all "+" to " "
+// try {
+// line = URLDecoder.decode(line, "UTF-8");
+// } catch (UnsupportedEncodingException e) {
+// // Not likely on modern system.
+// throw new Error("This system does not support UTF-8.", e);
+// } catch (IllegalArgumentException e) {
+// // Malformed URI sequence.
+// throw new IllegalArgumentException(
+// "Illegal escape in patch_fromText: " + line, e);
+// }
+// if (sign == '-') {
+// // Deletion.
+// patch.diffs.add(new Diff(Operation.DELETE, line));
+// } else if (sign == '+') {
+// // Insertion.
+// patch.diffs.add(new Diff(Operation.INSERT, line));
+// } else if (sign == ' ') {
+// // Minor equality.
+// patch.diffs.add(new Diff(Operation.EQUAL, line));
+// } else if (sign == '@') {
+// // Start of next patch.
+// break;
+// } else {
+// // WTF?
+// throw new IllegalArgumentException(
+// "Invalid patch mode '" + sign + "' in: " + line);
+// }
+// text.removeFirst();
+// }
+// }
+// return patches;
+// }
+
+
+ /**
+ * Class representing one diff operation.
+ */
+ public static class Diff {
+ /**
+ * One of: INSERT, DELETE or EQUAL.
+ */
+ public Operation operation;
+ /**
+ * The text associated with this diff operation.
+ */
+ public String text;
+
+ /**
+ * Constructor. Initializes the diff with the provided values.
+ *
+ * @param operation One of INSERT, DELETE or EQUAL.
+ * @param text The text being applied.
+ */
+ public Diff(Operation operation, String text) {
+ // Construct a diff with the specified operation and text.
+ this.operation = operation;
+ this.text = text;
+ }
+
+ /**
+ * Display a human-readable version of this Diff.
+ *
+ * @return text version.
+ */
+ public String toString() {
+ String prettyText = this.text.replace('\n', '\u00b6');
+ return "Diff(" + this.operation + ",\"" + prettyText + "\")";
+ }
+
+ /**
+ * Create a numeric hash value for a Diff.
+ * This function is not used by DMP.
+ *
+ * @return Hash value.
+ */
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = (operation == null) ? 0 : operation.hashCode();
+ result += prime * ((text == null) ? 0 : text.hashCode());
+ return result;
+ }
+
+ /**
+ * Is this Diff equivalent to another Diff?
+ *
+ * @param obj Another Diff to compare against.
+ * @return true or false.
+ */
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ Diff other = (Diff) obj;
+ if (operation != other.operation) {
+ return false;
+ }
+ if (text == null) {
+ if (other.text != null) {
+ return false;
+ }
+ } else if (!text.equals(other.text)) {
+ return false;
+ }
+ return true;
+ }
+ }
+
+
+///**
+// * Class representing one patch operation.
+// */
+//public static class Patch {
+// public LinkedList diffs;
+// public int start1;
+// public int start2;
+// public int length1;
+// public int length2;
+//
+// /**
+// * Constructor. Initializes with an empty list of diffs.
+// */
+// public Patch() {
+// this.diffs = new LinkedList();
+// }
+//
+// /**
+// * Emulate GNU diff's format.
+// * Header: @@ -382,8 +481,9 @@
+// * Indices are printed as 1-based, not 0-based.
+// *
+// * @return The GNU diff string.
+// */
+// public String toString() {
+// String coords1, coords2;
+// if (this.length1 == 0) {
+// coords1 = this.start1 + ",0";
+// } else if (this.length1 == 1) {
+// coords1 = Integer.toString(this.start1 + 1);
+// } else {
+// coords1 = (this.start1 + 1) + "," + this.length1;
+// }
+// if (this.length2 == 0) {
+// coords2 = this.start2 + ",0";
+// } else if (this.length2 == 1) {
+// coords2 = Integer.toString(this.start2 + 1);
+// } else {
+// coords2 = (this.start2 + 1) + "," + this.length2;
+// }
+// StringBuilder text = new StringBuilder();
+// text.append("@@ -").append(coords1).append(" +").append(coords2)
+// .append(" @@\n");
+// // Escape the body of the patch with %xx notation.
+// for (Diff aDiff : this.diffs) {
+// switch (aDiff.operation) {
+// case INSERT:
+// text.append('+');
+// break;
+// case DELETE:
+// text.append('-');
+// break;
+// case EQUAL:
+// text.append(' ');
+// break;
+// }
+// try {
+// text.append(URLEncoder.encode(aDiff.text, "UTF-8").replace('+', ' '))
+// .append("\n");
+// } catch (UnsupportedEncodingException e) {
+// // Not likely on modern system.
+// throw new Error("This system does not support UTF-8.", e);
+// }
+// }
+// return unescapeForEncodeUriCompatability(text.toString());
+// }
+//
+//}
+//
+// /**
+// * Unescape selected chars for compatability with JavaScript's encodeURI.
+// * In speed critical applications this could be dropped since the
+// * receiving application will certainly decode these fine.
+// * Note that this function is case-sensitive. Thus "%3f" would not be
+// * unescaped. But this is ok because it is only called with the output of
+// * URLEncoder.encode which returns uppercase hex.
+// *
+// * Example: "%3F" -> "?", "%24" -> "$", etc.
+// *
+// * @param str The string to escape.
+// * @return The escaped string.
+// */
+// private static String unescapeForEncodeUriCompatability(String str) {
+// return str.replace("%21", "!").replace("%7E", "~")
+// .replace("%27", "'").replace("%28", "(").replace("%29", ")")
+// .replace("%3B", ";").replace("%2F", "/").replace("%3F", "?")
+// .replace("%3A", ":").replace("%40", "@").replace("%26", "&")
+// .replace("%3D", "=").replace("%2B", "+").replace("%24", "$")
+// .replace("%2C", ",").replace("%23", "#");
+// }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java
new file mode 100644
index 00000000..c9902f3e
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/EmphasisEditHandler.java
@@ -0,0 +1,54 @@
+package io.noties.markwon.editor.handler;
+
+import android.text.Editable;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.core.spans.EmphasisSpan;
+import io.noties.markwon.editor.AbstractEditHandler;
+import io.noties.markwon.editor.MarkwonEditorUtils;
+import io.noties.markwon.editor.PersistedSpans;
+
+/**
+ * @since 4.2.0
+ */
+public class EmphasisEditHandler extends AbstractEditHandler {
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(EmphasisSpan.class, new PersistedSpans.SpanFactory() {
+ @NonNull
+ @Override
+ public EmphasisSpan create() {
+ return new EmphasisSpan();
+ }
+ });
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull EmphasisSpan span,
+ int spanStart,
+ int spanTextLength) {
+ final MarkwonEditorUtils.Match match =
+ MarkwonEditorUtils.findDelimited(input, spanStart, "*", "_");
+ if (match != null) {
+ editable.setSpan(
+ persistedSpans.get(EmphasisSpan.class),
+ match.start(),
+ match.end(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return EmphasisSpan.class;
+ }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java
new file mode 100644
index 00000000..ab53a8d2
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/handler/StrongEmphasisEditHandler.java
@@ -0,0 +1,62 @@
+package io.noties.markwon.editor.handler;
+
+import android.text.Editable;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.core.spans.StrongEmphasisSpan;
+import io.noties.markwon.editor.AbstractEditHandler;
+import io.noties.markwon.editor.MarkwonEditorUtils;
+import io.noties.markwon.editor.PersistedSpans;
+
+/**
+ * @since 4.2.0
+ */
+public class StrongEmphasisEditHandler extends AbstractEditHandler {
+
+ @NonNull
+ public static StrongEmphasisEditHandler create() {
+ return new StrongEmphasisEditHandler();
+ }
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(StrongEmphasisSpan.class, new PersistedSpans.SpanFactory() {
+ @NonNull
+ @Override
+ public StrongEmphasisSpan create() {
+ return new StrongEmphasisSpan();
+ }
+ });
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull StrongEmphasisSpan span,
+ int spanStart,
+ int spanTextLength) {
+ // inline spans can delimit other inline spans,
+ // for example: `**_~~hey~~_**`, this is why we must additionally find delimiter used
+ // and its actual start/end positions
+ final MarkwonEditorUtils.Match match =
+ MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__");
+ if (match != null) {
+ editable.setSpan(
+ persistedSpans.get(StrongEmphasisSpan.class),
+ match.start(),
+ match.end(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return StrongEmphasisSpan.class;
+ }
+}
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java
new file mode 100644
index 00000000..bc6422c9
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorImplTest.java
@@ -0,0 +1,42 @@
+package io.noties.markwon.editor;
+
+import android.text.SpannableStringBuilder;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import io.noties.markwon.Markwon;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class MarkwonEditorImplTest {
+
+ @Test
+ public void process() {
+ // create markwon
+ final Markwon markwon = Markwon.create(RuntimeEnvironment.application);
+
+ // default punctuation
+ final MarkwonEditor editor = MarkwonEditor.create(markwon);
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder("**bold**");
+
+ editor.process(builder);
+
+ final PunctuationSpan[] spans = builder.getSpans(0, builder.length(), PunctuationSpan.class);
+ assertEquals(2, spans.length);
+
+ final PunctuationSpan first = spans[0];
+ assertEquals(0, builder.getSpanStart(first));
+ assertEquals(2, builder.getSpanEnd(first));
+
+ final PunctuationSpan second = spans[1];
+ assertEquals(6, builder.getSpanStart(second));
+ assertEquals(8, builder.getSpanEnd(second));
+ }
+}
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java
new file mode 100644
index 00000000..8554d2bb
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTest.java
@@ -0,0 +1,30 @@
+package io.noties.markwon.editor;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import io.noties.markwon.Markwon;
+import io.noties.markwon.editor.MarkwonEditor.Builder;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class MarkwonEditorTest {
+
+ @Test
+ public void builder_no_config() {
+ // must create a default instance without exceptions
+
+ try {
+ new Builder(mock(Markwon.class)).build();
+ assertTrue(true);
+ } catch (Throwable t) {
+ fail(t.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java
new file mode 100644
index 00000000..5066c6d9
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorTextWatcherTest.java
@@ -0,0 +1,142 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+import android.widget.EditText;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.concurrent.ExecutorService;
+
+import io.noties.markwon.editor.MarkwonEditor.PreRenderResult;
+import io.noties.markwon.editor.MarkwonEditor.PreRenderResultListener;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.RETURNS_MOCKS;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class MarkwonEditorTextWatcherTest {
+
+ @Test
+ public void w_process() {
+
+ final MarkwonEditor editor = mock(MarkwonEditor.class);
+ final Editable editable = mock(Editable.class);
+
+ final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withProcess(editor);
+
+ watcher.afterTextChanged(editable);
+
+ verify(editor, times(1)).process(eq(editable));
+ }
+
+ @Test
+ public void w_pre_render() {
+
+ final MarkwonEditor editor = mock(MarkwonEditor.class);
+ final Editable editable = mock(Editable.class);
+ final ExecutorService service = mock(ExecutorService.class);
+ final EditText editText = mock(EditText.class);
+
+ when(editable.getSpans(anyInt(), anyInt(), any(Class.class))).thenReturn(new Object[0]);
+
+ when(editText.getText()).thenReturn(editable);
+
+ when(service.submit(any(Runnable.class))).thenAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) {
+ ((Runnable) invocation.getArgument(0)).run();
+ return null;
+ }
+ });
+
+ doAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) {
+ ((Runnable) invocation.getArgument(0)).run();
+ return null;
+ }
+ }).when(editText).post(any(Runnable.class));
+
+ final MarkwonEditorTextWatcher watcher = MarkwonEditorTextWatcher.withPreRender(
+ editor,
+ service,
+ editText);
+
+ watcher.afterTextChanged(editable);
+
+ final ArgumentCaptor captor =
+ ArgumentCaptor.forClass(PreRenderResultListener.class);
+
+ verify(service, times(1)).submit(any(Runnable.class));
+ verify(editor, times(1)).preRender(any(Editable.class), captor.capture());
+
+ final PreRenderResultListener listener = captor.getValue();
+ final PreRenderResult result = mock(PreRenderResult.class);
+
+ // for simplicity return the same editable instance (same hashCode)
+ when(result.resultEditable()).thenReturn(editable);
+
+ listener.onPreRenderResult(result);
+
+ // if we would check for hashCode then this method would've been invoked
+// verify(result, times(1)).resultEditable();
+ verify(result, times(1)).dispatchTo(eq(editable));
+ }
+
+ @Test
+ public void pre_render_posts_exception_to_main_thread() {
+
+ final RuntimeException e = new RuntimeException();
+
+ final MarkwonEditor editor = mock(MarkwonEditor.class);
+ final ExecutorService service = mock(ExecutorService.class);
+ final EditText editText = mock(EditText.class, RETURNS_MOCKS);
+
+ doAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) {
+ throw e;
+ }
+ }).when(editor).preRender(any(Editable.class), any(PreRenderResultListener.class));
+
+ when(service.submit(any(Runnable.class))).thenAnswer(new Answer() {
+ @Override
+ public Object answer(InvocationOnMock invocation) {
+ ((Runnable) invocation.getArgument(0)).run();
+ return null;
+ }
+ });
+
+ final ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class);
+
+ final MarkwonEditorTextWatcher textWatcher =
+ MarkwonEditorTextWatcher.withPreRender(editor, service, editText);
+
+ textWatcher.afterTextChanged(mock(Editable.class, RETURNS_MOCKS));
+
+ verify(editText, times(1)).post(captor.capture());
+
+ try {
+ captor.getValue().run();
+ fail();
+ } catch (Throwable t) {
+ assertEquals(e, t.getCause());
+ }
+ }
+}
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java
new file mode 100644
index 00000000..2aa56b95
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/MarkwonEditorUtilsTest.java
@@ -0,0 +1,109 @@
+package io.noties.markwon.editor;
+
+import android.text.SpannableStringBuilder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import io.noties.markwon.editor.MarkwonEditorUtils.Match;
+
+import static io.noties.markwon.editor.MarkwonEditorUtils.findDelimited;
+import static io.noties.markwon.editor.SpannableUtils.append;
+import static java.lang.String.format;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class MarkwonEditorUtilsTest {
+
+ @Test
+ public void extract_spans() {
+
+ final class One {
+ }
+ final class Two {
+ }
+ final class Three {
+ }
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder();
+ append(builder, "one", new One());
+ append(builder, "two", new Two(), new Two());
+ append(builder, "three", new Three(), new Three(), new Three());
+
+ final Map, List> map = MarkwonEditorUtils.extractSpans(
+ builder,
+ Arrays.asList(One.class, Three.class));
+
+ assertEquals(2, map.size());
+
+ assertNotNull(map.get(One.class));
+ assertNull(map.get(Two.class));
+ assertNotNull(map.get(Three.class));
+
+ //noinspection ConstantConditions
+ assertEquals(1, map.get(One.class).size());
+ //noinspection ConstantConditions
+ assertEquals(3, map.get(Three.class).size());
+ }
+
+ @Test
+ public void delimited_single() {
+ final String input = "**bold**";
+ final Match match = findDelimited(input, 0, "**");
+ assertMatched(input, match, "**", 0, input.length());
+ }
+
+ @Test
+ public void delimited_multiple() {
+ final String input = "**bold**";
+ final Match match = findDelimited(input, 0, "**", "__");
+ assertMatched(input, match, "**", 0, input.length());
+ }
+
+ @Test
+ public void delimited_em() {
+ // for example we will try to match `*` or `_` and our implementation will find first
+ final String input = "**_em_**"; // problematic for em...
+ final Match match = findDelimited(input, 0, "_", "*");
+ assertMatched(input, match, "_", 2, 6);
+ }
+
+ @Test
+ public void delimited_bold_em_strike() {
+ final String input = "**_~~dude~~_**";
+
+ final Match bold = findDelimited(input, 0, "**", "__");
+ final Match em = findDelimited(input, 0, "*", "_");
+ final Match strike = findDelimited(input, 0, "~~");
+
+ assertMatched(input, bold, "**", 0, input.length());
+ assertMatched(input, em, "_", 2, 12);
+ assertMatched(input, strike, "~~", 3, 11);
+ }
+
+ private static void assertMatched(
+ @NonNull String input,
+ @Nullable Match match,
+ @NonNull String delimiter,
+ int start,
+ int end) {
+ assertNotNull(format(Locale.ROOT, "delimiter: '%s', input: '%s'", delimiter, input), match);
+ final String m = format(Locale.ROOT, "input: '%s', match: %s", input, match);
+ assertEquals(m, delimiter, match.delimiter());
+ assertEquals(m, start, match.start());
+ assertEquals(m, end, match.end());
+ }
+}
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java b/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java
new file mode 100644
index 00000000..36be6ce7
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/PersistedSpansTest.java
@@ -0,0 +1,96 @@
+package io.noties.markwon.editor;
+
+import android.text.SpannableStringBuilder;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import io.noties.markwon.editor.PersistedSpans.Impl;
+import io.noties.markwon.editor.PersistedSpans.SpanFactory;
+
+import static io.noties.markwon.editor.SpannableUtils.append;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class PersistedSpansTest {
+
+ @Test
+ public void not_included() {
+ // When a span that is not included is requested -> exception is raised
+
+ final Map, SpanFactory> map = Collections.emptyMap();
+
+ final Impl impl = new Impl(new SpannableStringBuilder(), map);
+
+ try {
+ impl.get(Object.class);
+ fail();
+ } catch (IllegalStateException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("not registered, use PersistedSpans.Builder#persistSpan method to register"));
+ }
+ }
+
+ @Test
+ public void re_use() {
+ // when a span is present in supplied spannable -> it will be used
+
+ final class One {
+ }
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder();
+ final One one = new One();
+ append(builder, "One", one);
+
+ final Map, SpanFactory> map = new HashMap, SpanFactory>() {{
+ // null in case it _will_ be used -> thus NPE
+ put(One.class, null);
+ }};
+
+ final Impl impl = new Impl(builder, map);
+
+ assertEquals(one, impl.get(One.class));
+ }
+
+ @Test
+ public void factory_create() {
+ // when span is not present in spannable -> new one will be created via factory
+
+ final class Two {
+ }
+
+ final SpannableStringBuilder builder = new SpannableStringBuilder();
+ final Two two = new Two();
+ append(builder, "two", two);
+
+ final SpanFactory factory = mock(SpanFactory.class);
+
+ final Map, SpanFactory> map = new HashMap, SpanFactory>() {{
+ put(Two.class, factory);
+ }};
+
+ final Impl impl = new Impl(builder, map);
+
+ // first one will be the same as we had created before,
+ // second one will be created via factory
+
+ assertEquals(two, impl.get(Two.class));
+
+ verify(factory, never()).create();
+
+ impl.get(Two.class);
+ verify(factory, times(1)).create();
+ }
+}
\ No newline at end of file
diff --git a/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java b/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java
new file mode 100644
index 00000000..858a239e
--- /dev/null
+++ b/markwon-editor/src/test/java/io/noties/markwon/editor/SpannableUtils.java
@@ -0,0 +1,21 @@
+package io.noties.markwon.editor;
+
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+abstract class SpannableUtils {
+
+ static void append(@NonNull SpannableStringBuilder builder, @NonNull String text, Object... spans) {
+ final int start = builder.length();
+ builder.append(text);
+ final int end = builder.length();
+ for (Object span : spans) {
+ builder.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+
+ private SpannableUtils() {
+ }
+}
diff --git a/markwon-image-coil/README.md b/markwon-image-coil/README.md
new file mode 100644
index 00000000..74e61881
--- /dev/null
+++ b/markwon-image-coil/README.md
@@ -0,0 +1,3 @@
+# Images (Coil)
+
+https://noties.io/Markwon/docs/v4/image-coil/
\ No newline at end of file
diff --git a/markwon-image-coil/build.gradle b/markwon-image-coil/build.gradle
new file mode 100644
index 00000000..aa760dfb
--- /dev/null
+++ b/markwon-image-coil/build.gradle
@@ -0,0 +1,21 @@
+apply plugin: 'com.android.library'
+
+android {
+
+ compileSdkVersion config['compile-sdk']
+ buildToolsVersion config['build-tools']
+
+ defaultConfig {
+ minSdkVersion config['min-sdk']
+ targetSdkVersion config['target-sdk']
+ versionCode 1
+ versionName version
+ }
+}
+
+dependencies {
+ api project(':markwon-core')
+ api deps['coil']
+}
+
+registerArtifact(this)
diff --git a/markwon-image-coil/gradle.properties b/markwon-image-coil/gradle.properties
new file mode 100644
index 00000000..489bbf6e
--- /dev/null
+++ b/markwon-image-coil/gradle.properties
@@ -0,0 +1,4 @@
+POM_NAME=Image Coil
+POM_ARTIFACT_ID=image-coil
+POM_DESCRIPTION=Markwon image loading module (based on Coil library)
+POM_PACKAGING=aar
\ No newline at end of file
diff --git a/markwon-image-coil/src/main/AndroidManifest.xml b/markwon-image-coil/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..b2f53ca9
--- /dev/null
+++ b/markwon-image-coil/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/markwon-image-coil/src/main/java/io/noties/markwon/image/coil/CoilImagesPlugin.java b/markwon-image-coil/src/main/java/io/noties/markwon/image/coil/CoilImagesPlugin.java
new file mode 100644
index 00000000..5d15dcfd
--- /dev/null
+++ b/markwon-image-coil/src/main/java/io/noties/markwon/image/coil/CoilImagesPlugin.java
@@ -0,0 +1,187 @@
+package io.noties.markwon.image.coil;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.Spanned;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.node.Image;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import coil.Coil;
+import coil.ImageLoader;
+import coil.api.ImageLoaders;
+import coil.request.LoadRequest;
+import coil.request.RequestDisposable;
+import coil.target.Target;
+import io.noties.markwon.AbstractMarkwonPlugin;
+import io.noties.markwon.MarkwonConfiguration;
+import io.noties.markwon.MarkwonSpansFactory;
+import io.noties.markwon.image.AsyncDrawable;
+import io.noties.markwon.image.AsyncDrawableLoader;
+import io.noties.markwon.image.AsyncDrawableScheduler;
+import io.noties.markwon.image.DrawableUtils;
+import io.noties.markwon.image.ImageSpanFactory;
+
+/**
+ * @author Tyler Wong
+ * @since 4.2.0
+ */
+public class CoilImagesPlugin extends AbstractMarkwonPlugin {
+
+ public interface CoilStore {
+
+ @NonNull
+ LoadRequest load(@NonNull AsyncDrawable drawable);
+
+ void cancel(@NonNull RequestDisposable disposable);
+ }
+
+ @NonNull
+ public static CoilImagesPlugin create(@NonNull final Context context) {
+ return create(new CoilStore() {
+ @NonNull
+ @Override
+ public LoadRequest load(@NonNull AsyncDrawable drawable) {
+ return ImageLoaders.newLoadBuilder(Coil.loader(), context)
+ .data(drawable.getDestination())
+ .build();
+ }
+
+ @Override
+ public void cancel(@NonNull RequestDisposable disposable) {
+ disposable.dispose();
+ }
+ }, Coil.loader());
+ }
+
+ @NonNull
+ public static CoilImagesPlugin create(@NonNull final Context context,
+ @NonNull final ImageLoader imageLoader) {
+ return create(new CoilStore() {
+ @NonNull
+ @Override
+ public LoadRequest load(@NonNull AsyncDrawable drawable) {
+ return ImageLoaders.newLoadBuilder(imageLoader, context)
+ .data(drawable.getDestination())
+ .build();
+ }
+
+ @Override
+ public void cancel(@NonNull RequestDisposable disposable) {
+ disposable.dispose();
+ }
+ }, imageLoader);
+ }
+
+ @NonNull
+ public static CoilImagesPlugin create(@NonNull final CoilStore coilStore,
+ @NonNull final ImageLoader imageLoader) {
+ return new CoilImagesPlugin(coilStore, imageLoader);
+ }
+
+ private final CoilAsyncDrawableLoader coilAsyncDrawableLoader;
+
+ @SuppressWarnings("WeakerAccess")
+ CoilImagesPlugin(@NonNull CoilStore coilStore, @NonNull ImageLoader imageLoader) {
+ this.coilAsyncDrawableLoader = new CoilAsyncDrawableLoader(coilStore, imageLoader);
+ }
+
+ @Override
+ public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) {
+ builder.setFactory(Image.class, new ImageSpanFactory());
+ }
+
+ @Override
+ public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) {
+ builder.asyncDrawableLoader(coilAsyncDrawableLoader);
+ }
+
+ @Override
+ public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
+ AsyncDrawableScheduler.unschedule(textView);
+ }
+
+ @Override
+ public void afterSetText(@NonNull TextView textView) {
+ AsyncDrawableScheduler.schedule(textView);
+ }
+
+ private static class CoilAsyncDrawableLoader extends AsyncDrawableLoader {
+
+ private final CoilStore coilStore;
+ private final ImageLoader imageLoader;
+ private final Map cache = new HashMap<>(2);
+
+ CoilAsyncDrawableLoader(@NonNull CoilStore coilStore, @NonNull ImageLoader imageLoader) {
+ this.coilStore = coilStore;
+ this.imageLoader = imageLoader;
+ }
+
+ @Override
+ public void load(@NonNull AsyncDrawable drawable) {
+ final Target target = new AsyncDrawableTarget(drawable);
+ LoadRequest request = coilStore.load(drawable).newBuilder()
+ .target(target)
+ .build();
+ RequestDisposable disposable = imageLoader.load(request);
+ cache.put(drawable, disposable);
+ }
+
+ @Override
+ public void cancel(@NonNull AsyncDrawable drawable) {
+ final RequestDisposable disposable = cache.remove(drawable);
+ if (disposable != null) {
+ coilStore.cancel(disposable);
+ }
+ }
+
+ @Nullable
+ @Override
+ public Drawable placeholder(@NonNull AsyncDrawable drawable) {
+ return null;
+ }
+
+ private class AsyncDrawableTarget implements Target {
+
+ private final AsyncDrawable drawable;
+
+ AsyncDrawableTarget(@NonNull AsyncDrawable drawable) {
+ this.drawable = drawable;
+ }
+
+ @Override
+ public void onSuccess(@NonNull Drawable loadedDrawable) {
+ if (cache.remove(drawable) != null) {
+ if (drawable.isAttached()) {
+ DrawableUtils.applyIntrinsicBoundsIfEmpty(loadedDrawable);
+ drawable.setResult(loadedDrawable);
+ }
+ }
+ }
+
+ @Override
+ public void onStart(@Nullable Drawable placeholder) {
+ if (placeholder != null && drawable.isAttached()) {
+ DrawableUtils.applyIntrinsicBoundsIfEmpty(placeholder);
+ drawable.setResult(placeholder);
+ }
+ }
+
+ @Override
+ public void onError(@Nullable Drawable errorDrawable) {
+ if (cache.remove(drawable) != null) {
+ if (errorDrawable != null && drawable.isAttached()) {
+ DrawableUtils.applyIntrinsicBoundsIfEmpty(errorDrawable);
+ drawable.setResult(errorDrawable);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/markwon-image/src/main/java/io/noties/markwon/image/svg/SvgPictureMediaDecoder.java b/markwon-image/src/main/java/io/noties/markwon/image/svg/SvgPictureMediaDecoder.java
new file mode 100644
index 00000000..0035720f
--- /dev/null
+++ b/markwon-image/src/main/java/io/noties/markwon/image/svg/SvgPictureMediaDecoder.java
@@ -0,0 +1,51 @@
+package io.noties.markwon.image.svg;
+
+import android.graphics.Picture;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.PictureDrawable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.caverock.androidsvg.SVG;
+import com.caverock.androidsvg.SVGParseException;
+
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.Collections;
+
+import io.noties.markwon.image.MediaDecoder;
+
+/**
+ * @since 4.2.0
+ */
+public class SvgPictureMediaDecoder extends MediaDecoder {
+
+ public static final String CONTENT_TYPE = "image/svg+xml";
+
+ @NonNull
+ public static SvgPictureMediaDecoder create() {
+ return new SvgPictureMediaDecoder();
+ }
+
+ @NonNull
+ @Override
+ public Drawable decode(@Nullable String contentType, @NonNull InputStream inputStream) {
+
+ final SVG svg;
+ try {
+ svg = SVG.getFromInputStream(inputStream);
+ } catch (SVGParseException e) {
+ throw new IllegalStateException("Exception decoding SVG", e);
+ }
+
+ final Picture picture = svg.renderToPicture();
+ return new PictureDrawable(picture);
+ }
+
+ @NonNull
+ @Override
+ public Collection supportedTypes() {
+ return Collections.singleton(CONTENT_TYPE);
+ }
+}
diff --git a/markwon-inline-parser/README.md b/markwon-inline-parser/README.md
new file mode 100644
index 00000000..bcfa3802
--- /dev/null
+++ b/markwon-inline-parser/README.md
@@ -0,0 +1,16 @@
+# Inline parser
+
+**Experimental** due to usage of internal (but still visible) classes of commonmark-java:
+
+```java
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.Delimiter;
+import org.commonmark.internal.ReferenceParser;
+import org.commonmark.internal.util.Escaping;
+import org.commonmark.internal.util.Html5Entities;
+import org.commonmark.internal.util.Parsing;
+import org.commonmark.internal.inline.AsteriskDelimiterProcessor;
+import org.commonmark.internal.inline.UnderscoreDelimiterProcessor;
+```
+
+`StaggeredDelimiterProcessor` class source is copied (required for InlineParser)
\ No newline at end of file
diff --git a/markwon-inline-parser/build.gradle b/markwon-inline-parser/build.gradle
new file mode 100644
index 00000000..703a18ff
--- /dev/null
+++ b/markwon-inline-parser/build.gradle
@@ -0,0 +1,26 @@
+apply plugin: 'com.android.library'
+
+android {
+
+ compileSdkVersion config['compile-sdk']
+ buildToolsVersion config['build-tools']
+
+ defaultConfig {
+ minSdkVersion config['min-sdk']
+ targetSdkVersion config['target-sdk']
+ versionCode 1
+ versionName version
+ }
+}
+
+dependencies {
+ api deps['x-annotations']
+ api deps['commonmark']
+
+ deps['test'].with {
+ testImplementation it['junit']
+ testImplementation it['commonmark-test-util']
+ }
+}
+
+registerArtifact(this)
\ No newline at end of file
diff --git a/markwon-inline-parser/gradle.properties b/markwon-inline-parser/gradle.properties
new file mode 100644
index 00000000..264a18ee
--- /dev/null
+++ b/markwon-inline-parser/gradle.properties
@@ -0,0 +1,4 @@
+POM_NAME=Inline Parser
+POM_ARTIFACT_ID=inline-parser
+POM_DESCRIPTION=Markwon customizable commonmark-java InlineParser
+POM_PACKAGING=aar
\ No newline at end of file
diff --git a/markwon-inline-parser/src/main/AndroidManifest.xml b/markwon-inline-parser/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..1a8bcbb5
--- /dev/null
+++ b/markwon-inline-parser/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/AutolinkInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/AutolinkInlineProcessor.java
new file mode 100644
index 00000000..cbba2763
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/AutolinkInlineProcessor.java
@@ -0,0 +1,44 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.node.Link;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+import java.util.regex.Pattern;
+
+/**
+ * Parses autolinks, for example {@code }
+ *
+ * @since 4.2.0
+ */
+public class AutolinkInlineProcessor extends InlineProcessor {
+
+ private static final Pattern EMAIL_AUTOLINK = Pattern
+ .compile("^<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>");
+
+ private static final Pattern AUTOLINK = Pattern
+ .compile("^<[a-zA-Z][a-zA-Z0-9.+-]{1,31}:[^<>\u0000-\u0020]*>");
+
+ @Override
+ public char specialCharacter() {
+ return '<';
+ }
+
+ @Override
+ protected Node parse() {
+ String m;
+ if ((m = match(EMAIL_AUTOLINK)) != null) {
+ String dest = m.substring(1, m.length() - 1);
+ Link node = new Link("mailto:" + dest, null);
+ node.appendChild(new Text(dest));
+ return node;
+ } else if ((m = match(AUTOLINK)) != null) {
+ String dest = m.substring(1, m.length() - 1);
+ Link node = new Link(dest, null);
+ node.appendChild(new Text(dest));
+ return node;
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackslashInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackslashInlineProcessor.java
new file mode 100644
index 00000000..c4afc3e0
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackslashInlineProcessor.java
@@ -0,0 +1,35 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.Node;
+
+import java.util.regex.Pattern;
+
+/**
+ * @since 4.2.0
+ */
+public class BackslashInlineProcessor extends InlineProcessor {
+
+ private static final Pattern ESCAPABLE = MarkwonInlineParser.ESCAPABLE;
+
+ @Override
+ public char specialCharacter() {
+ return '\\';
+ }
+
+ @Override
+ protected Node parse() {
+ index++;
+ Node node;
+ if (peek() == '\n') {
+ node = new HardLineBreak();
+ index++;
+ } else if (index < input.length() && ESCAPABLE.matcher(input.substring(index, index + 1)).matches()) {
+ node = text(input, index, index + 1);
+ index++;
+ } else {
+ node = text("\\");
+ }
+ return node;
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackticksInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackticksInlineProcessor.java
new file mode 100644
index 00000000..ef5be678
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BackticksInlineProcessor.java
@@ -0,0 +1,56 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.util.Parsing;
+import org.commonmark.node.Code;
+import org.commonmark.node.Node;
+
+import java.util.regex.Pattern;
+
+/**
+ * Parses inline code surrounded with {@code `} chars {@code `code`}
+ *
+ * @since 4.2.0
+ */
+public class BackticksInlineProcessor extends InlineProcessor {
+
+ private static final Pattern TICKS = Pattern.compile("`+");
+
+ private static final Pattern TICKS_HERE = Pattern.compile("^`+");
+
+ @Override
+ public char specialCharacter() {
+ return '`';
+ }
+
+ @Override
+ protected Node parse() {
+ String ticks = match(TICKS_HERE);
+ if (ticks == null) {
+ return null;
+ }
+ int afterOpenTicks = index;
+ String matched;
+ while ((matched = match(TICKS)) != null) {
+ if (matched.equals(ticks)) {
+ Code node = new Code();
+ String content = input.substring(afterOpenTicks, index - ticks.length());
+ content = content.replace('\n', ' ');
+
+ // spec: If the resulting string both begins and ends with a space character, but does not consist
+ // entirely of space characters, a single space character is removed from the front and back.
+ if (content.length() >= 3 &&
+ content.charAt(0) == ' ' &&
+ content.charAt(content.length() - 1) == ' ' &&
+ Parsing.hasNonSpace(content)) {
+ content = content.substring(1, content.length() - 1);
+ }
+
+ node.setLiteral(content);
+ return node;
+ }
+ }
+ // If we got here, we didn't match a closing backtick sequence.
+ index = afterOpenTicks;
+ return text(ticks);
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BangInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BangInlineProcessor.java
new file mode 100644
index 00000000..75d6fb03
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/BangInlineProcessor.java
@@ -0,0 +1,35 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+/**
+ * Parses markdown images {@code }
+ *
+ * @since 4.2.0
+ */
+public class BangInlineProcessor extends InlineProcessor {
+ @Override
+ public char specialCharacter() {
+ return '!';
+ }
+
+ @Override
+ protected Node parse() {
+ int startIndex = index;
+ index++;
+ if (peek() == '[') {
+ index++;
+
+ Text node = text("![");
+
+ // Add entry to stack for this opener
+ addBracket(Bracket.image(node, startIndex + 1, lastBracket(), lastDelimiter()));
+
+ return node;
+ } else {
+ return text("!");
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java
new file mode 100644
index 00000000..b9d2f867
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java
@@ -0,0 +1,140 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.util.Escaping;
+import org.commonmark.node.Image;
+import org.commonmark.node.Link;
+import org.commonmark.node.LinkReferenceDefinition;
+import org.commonmark.node.Node;
+
+import java.util.regex.Pattern;
+
+import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes;
+
+/**
+ * Parses markdown link or image, relies on {@link OpenBracketInlineProcessor}
+ * to handle start of these elements
+ *
+ * @since 4.2.0
+ */
+public class CloseBracketInlineProcessor extends InlineProcessor {
+
+ private static final Pattern WHITESPACE = MarkwonInlineParser.WHITESPACE;
+
+ @Override
+ public char specialCharacter() {
+ return ']';
+ }
+
+ @Override
+ protected Node parse() {
+ index++;
+ int startIndex = index;
+
+ // Get previous `[` or `![`
+ Bracket opener = lastBracket();
+ if (opener == null) {
+ // No matching opener, just return a literal.
+ return text("]");
+ }
+
+ if (!opener.allowed) {
+ // Matching opener but it's not allowed, just return a literal.
+ removeLastBracket();
+ return text("]");
+ }
+
+ // Check to see if we have a link/image
+
+ String dest = null;
+ String title = null;
+ boolean isLinkOrImage = false;
+
+ // Maybe a inline link like `[foo](/uri "title")`
+ if (peek() == '(') {
+ index++;
+ spnl();
+ if ((dest = parseLinkDestination()) != null) {
+ spnl();
+ // title needs a whitespace before
+ if (WHITESPACE.matcher(input.substring(index - 1, index)).matches()) {
+ title = parseLinkTitle();
+ spnl();
+ }
+ if (peek() == ')') {
+ index++;
+ isLinkOrImage = true;
+ } else {
+ index = startIndex;
+ }
+ }
+ }
+
+ // Maybe a reference link like `[foo][bar]`, `[foo][]` or `[foo]`
+ if (!isLinkOrImage) {
+
+ // See if there's a link label like `[bar]` or `[]`
+ int beforeLabel = index;
+ parseLinkLabel();
+ int labelLength = index - beforeLabel;
+ String ref = null;
+ if (labelLength > 2) {
+ ref = input.substring(beforeLabel, beforeLabel + labelLength);
+ } else if (!opener.bracketAfter) {
+ // If the second label is empty `[foo][]` or missing `[foo]`, then the first label is the reference.
+ // But it can only be a reference when there's no (unescaped) bracket in it.
+ // If there is, we don't even need to try to look up the reference. This is an optimization.
+ ref = input.substring(opener.index, startIndex);
+ }
+
+ if (ref != null) {
+ String label = Escaping.normalizeReference(ref);
+ LinkReferenceDefinition definition = context.getLinkReferenceDefinition(label);
+ if (definition != null) {
+ dest = definition.getDestination();
+ title = definition.getTitle();
+ isLinkOrImage = true;
+ }
+ }
+ }
+
+ if (isLinkOrImage) {
+ // If we got here, open is a potential opener
+ Node linkOrImage = opener.image ? new Image(dest, title) : new Link(dest, title);
+
+ Node node = opener.node.getNext();
+ while (node != null) {
+ Node next = node.getNext();
+ linkOrImage.appendChild(node);
+ node = next;
+ }
+
+ // Process delimiters such as emphasis inside link/image
+ processDelimiters(opener.previousDelimiter);
+ mergeChildTextNodes(linkOrImage);
+ // We don't need the corresponding text node anymore, we turned it into a link/image node
+ opener.node.unlink();
+ removeLastBracket();
+
+ // Links within links are not allowed. We found this link, so there can be no other link around it.
+ if (!opener.image) {
+ Bracket bracket = lastBracket();
+ while (bracket != null) {
+ if (!bracket.image) {
+ // Disallow link opener. It will still get matched, but will not result in a link.
+ bracket.allowed = false;
+ }
+ bracket = bracket.previous;
+ }
+ }
+
+ return linkOrImage;
+
+ } else { // no link or image
+ index = startIndex;
+ removeLastBracket();
+
+ return text("]");
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java
new file mode 100644
index 00000000..353f9902
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java
@@ -0,0 +1,32 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.util.Escaping;
+import org.commonmark.internal.util.Html5Entities;
+import org.commonmark.node.Node;
+
+import java.util.regex.Pattern;
+
+/**
+ * Parses HTML entities {@code &}
+ *
+ * @since 4.2.0
+ */
+public class EntityInlineProcessor extends InlineProcessor {
+
+ private static final Pattern ENTITY_HERE = Pattern.compile('^' + Escaping.ENTITY, Pattern.CASE_INSENSITIVE);
+
+ @Override
+ public char specialCharacter() {
+ return '&';
+ }
+
+ @Override
+ protected Node parse() {
+ String m;
+ if ((m = match(ENTITY_HERE)) != null) {
+ return text(Html5Entities.entityToString(m));
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java
new file mode 100644
index 00000000..d3bd579d
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java
@@ -0,0 +1,40 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.util.Parsing;
+import org.commonmark.node.HtmlInline;
+import org.commonmark.node.Node;
+
+import java.util.regex.Pattern;
+
+/**
+ * Parses inline HTML tags
+ *
+ * @since 4.2.0
+ */
+public class HtmlInlineProcessor extends InlineProcessor {
+
+ private static final String HTMLCOMMENT = "|";
+ private static final String PROCESSINGINSTRUCTION = "[<][?].*?[?][>]";
+ private static final String DECLARATION = "]*>";
+ private static final String CDATA = "";
+ private static final String HTMLTAG = "(?:" + Parsing.OPENTAG + "|" + Parsing.CLOSETAG + "|" + HTMLCOMMENT
+ + "|" + PROCESSINGINSTRUCTION + "|" + DECLARATION + "|" + CDATA + ")";
+ private static final Pattern HTML_TAG = Pattern.compile('^' + HTMLTAG, Pattern.CASE_INSENSITIVE);
+
+ @Override
+ public char specialCharacter() {
+ return '<';
+ }
+
+ @Override
+ protected Node parse() {
+ String m = match(HTML_TAG);
+ if (m != null) {
+ HtmlInline node = new HtmlInline();
+ node.setLiteral(m);
+ return node;
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineParserUtils.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineParserUtils.java
new file mode 100644
index 00000000..1ffb9131
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineParserUtils.java
@@ -0,0 +1,77 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+/**
+ * @since 4.2.0
+ */
+public abstract class InlineParserUtils {
+
+ public static void mergeTextNodesBetweenExclusive(Node fromNode, Node toNode) {
+ // No nodes between them
+ if (fromNode == toNode || fromNode.getNext() == toNode) {
+ return;
+ }
+
+ mergeTextNodesInclusive(fromNode.getNext(), toNode.getPrevious());
+ }
+
+ public static void mergeChildTextNodes(Node node) {
+ // No children or just one child node, no need for merging
+ if (node.getFirstChild() == node.getLastChild()) {
+ return;
+ }
+
+ mergeTextNodesInclusive(node.getFirstChild(), node.getLastChild());
+ }
+
+ public static void mergeTextNodesInclusive(Node fromNode, Node toNode) {
+ Text first = null;
+ Text last = null;
+ int length = 0;
+
+ Node node = fromNode;
+ while (node != null) {
+ if (node instanceof Text) {
+ Text text = (Text) node;
+ if (first == null) {
+ first = text;
+ }
+ length += text.getLiteral().length();
+ last = text;
+ } else {
+ mergeIfNeeded(first, last, length);
+ first = null;
+ last = null;
+ length = 0;
+ }
+ if (node == toNode) {
+ break;
+ }
+ node = node.getNext();
+ }
+
+ mergeIfNeeded(first, last, length);
+ }
+
+ public static void mergeIfNeeded(Text first, Text last, int textLength) {
+ if (first != null && last != null && first != last) {
+ StringBuilder sb = new StringBuilder(textLength);
+ sb.append(first.getLiteral());
+ Node node = first.getNext();
+ Node stop = last.getNext();
+ while (node != stop) {
+ sb.append(((Text) node).getLiteral());
+ Node unlink = node;
+ node = node.getNext();
+ unlink.unlink();
+ }
+ String literal = sb.toString();
+ first.setLiteral(literal);
+ }
+ }
+
+ private InlineParserUtils() {
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineProcessor.java
new file mode 100644
index 00000000..b7917578
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/InlineProcessor.java
@@ -0,0 +1,141 @@
+package io.noties.markwon.inlineparser;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.Delimiter;
+import org.commonmark.node.Link;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * @see AutolinkInlineProcessor
+ * @see BackslashInlineProcessor
+ * @see BackticksInlineProcessor
+ * @see BangInlineProcessor
+ * @see CloseBracketInlineProcessor
+ * @see EntityInlineProcessor
+ * @see HtmlInlineProcessor
+ * @see NewLineInlineProcessor
+ * @see OpenBracketInlineProcessor
+ * @see MarkwonInlineParser.FactoryBuilder#addInlineProcessor(InlineProcessor)
+ * @see MarkwonInlineParser.FactoryBuilder#excludeInlineProcessor(Class)
+ * @since 4.2.0
+ */
+public abstract class InlineProcessor {
+
+ /**
+ * Special character that triggers parsing attempt
+ */
+ public abstract char specialCharacter();
+
+ /**
+ * @return boolean indicating if parsing succeeded
+ */
+ @Nullable
+ protected abstract Node parse();
+
+
+ protected MarkwonInlineParserContext context;
+ protected Node block;
+ protected String input;
+ protected int index;
+
+ @Nullable
+ public Node parse(@NonNull MarkwonInlineParserContext context) {
+ this.context = context;
+ this.block = context.block();
+ this.input = context.input();
+ this.index = context.index();
+
+ final Node result = parse();
+
+ // synchronize index
+ context.setIndex(index);
+
+ return result;
+ }
+
+ protected Bracket lastBracket() {
+ return context.lastBracket();
+ }
+
+ protected Delimiter lastDelimiter() {
+ return context.lastDelimiter();
+ }
+
+ protected void addBracket(Bracket bracket) {
+ context.addBracket(bracket);
+ }
+
+ protected void removeLastBracket() {
+ context.removeLastBracket();
+ }
+
+ protected void spnl() {
+ context.setIndex(index);
+ context.spnl();
+ index = context.index();
+ }
+
+ @Nullable
+ protected String match(@NonNull Pattern re) {
+ // before trying to match, we must notify context about our index (which we store additionally here)
+ context.setIndex(index);
+
+ final String result = context.match(re);
+
+ // after match we must reflect index change here
+ this.index = context.index();
+
+ return result;
+ }
+
+ @Nullable
+ protected String parseLinkDestination() {
+ context.setIndex(index);
+ final String result = context.parseLinkDestination();
+ this.index = context.index();
+ return result;
+ }
+
+ @Nullable
+ protected String parseLinkTitle() {
+ context.setIndex(index);
+ final String result = context.parseLinkTitle();
+ this.index = context.index();
+ return result;
+ }
+
+ protected int parseLinkLabel() {
+ context.setIndex(index);
+ final int result = context.parseLinkLabel();
+ this.index = context.index();
+ return result;
+ }
+
+ protected void processDelimiters(Delimiter stackBottom) {
+ context.setIndex(index);
+ context.processDelimiters(stackBottom);
+ this.index = context.index();
+ }
+
+ @NonNull
+ protected Text text(@NonNull String text) {
+ return context.text(text);
+ }
+
+ @NonNull
+ protected Text text(@NonNull String text, int start, int end) {
+ return context.text(text, start, end);
+ }
+
+ protected char peek() {
+ context.setIndex(index);
+ return context.peek();
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParser.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParser.java
new file mode 100644
index 00000000..2b2f26b3
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParser.java
@@ -0,0 +1,824 @@
+package io.noties.markwon.inlineparser;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.Delimiter;
+import org.commonmark.internal.inline.AsteriskDelimiterProcessor;
+import org.commonmark.internal.inline.UnderscoreDelimiterProcessor;
+import org.commonmark.internal.util.Escaping;
+import org.commonmark.internal.util.LinkScanner;
+import org.commonmark.node.LinkReferenceDefinition;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+import org.commonmark.parser.InlineParser;
+import org.commonmark.parser.InlineParserContext;
+import org.commonmark.parser.InlineParserFactory;
+import org.commonmark.parser.delimiter.DelimiterProcessor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes;
+import static io.noties.markwon.inlineparser.InlineParserUtils.mergeTextNodesBetweenExclusive;
+
+/**
+ * @see #factoryBuilder()
+ * @see #factoryBuilderNoDefaults()
+ * @see FactoryBuilder
+ * @since 4.2.0
+ */
+public class MarkwonInlineParser implements InlineParser, MarkwonInlineParserContext {
+
+ @SuppressWarnings("unused")
+ public interface FactoryBuilder {
+
+ /**
+ * @see InlineProcessor
+ */
+ @NonNull
+ FactoryBuilder addInlineProcessor(@NonNull InlineProcessor processor);
+
+ /**
+ * @see AsteriskDelimiterProcessor
+ * @see UnderscoreDelimiterProcessor
+ */
+ @NonNull
+ FactoryBuilder addDelimiterProcessor(@NonNull DelimiterProcessor processor);
+
+ /**
+ * Indicate if markdown references are enabled. By default = `true`
+ */
+ @NonNull
+ FactoryBuilder referencesEnabled(boolean referencesEnabled);
+
+ @NonNull
+ FactoryBuilder excludeInlineProcessor(@NonNull Class extends InlineProcessor> processor);
+
+ @NonNull
+ FactoryBuilder excludeDelimiterProcessor(@NonNull Class extends DelimiterProcessor> processor);
+
+ @NonNull
+ InlineParserFactory build();
+ }
+
+ public interface FactoryBuilderNoDefaults extends FactoryBuilder {
+ /**
+ * Includes all default delimiter and inline processors, and sets {@code referencesEnabled=true}.
+ * Useful with subsequent calls to {@link #excludeInlineProcessor(Class)} or {@link #excludeDelimiterProcessor(Class)}
+ */
+ @NonNull
+ FactoryBuilder includeDefaults();
+ }
+
+ /**
+ * Creates an instance of {@link FactoryBuilder} and includes all defaults.
+ *
+ * @see #factoryBuilderNoDefaults()
+ */
+ @NonNull
+ public static FactoryBuilder factoryBuilder() {
+ return new FactoryBuilderImpl().includeDefaults();
+ }
+
+ /**
+ * NB, this return an empty builder, so if no {@link FactoryBuilderNoDefaults#includeDefaults()}
+ * is called, it means effectively no inline parsing (unless further calls
+ * to {@link FactoryBuilder#addInlineProcessor(InlineProcessor)} or {@link FactoryBuilder#addDelimiterProcessor(DelimiterProcessor)}).
+ */
+ @NonNull
+ public static FactoryBuilderNoDefaults factoryBuilderNoDefaults() {
+ return new FactoryBuilderImpl();
+ }
+
+ private static final String ASCII_PUNCTUATION = "!\"#\\$%&'\\(\\)\\*\\+,\\-\\./:;<=>\\?@\\[\\\\\\]\\^_`\\{\\|\\}~";
+ private static final Pattern PUNCTUATION = Pattern
+ .compile("^[" + ASCII_PUNCTUATION + "\\p{Pc}\\p{Pd}\\p{Pe}\\p{Pf}\\p{Pi}\\p{Po}\\p{Ps}]");
+
+ private static final Pattern SPNL = Pattern.compile("^ *(?:\n *)?");
+
+ private static final Pattern UNICODE_WHITESPACE_CHAR = Pattern.compile("^[\\p{Zs}\t\r\n\f]");
+
+ static final Pattern ESCAPABLE = Pattern.compile('^' + Escaping.ESCAPABLE);
+ static final Pattern WHITESPACE = Pattern.compile("\\s+");
+
+ private final InlineParserContext inlineParserContext;
+
+ private final boolean referencesEnabled;
+
+ private final BitSet specialCharacters;
+ private final Map> inlineProcessors;
+ private final Map delimiterProcessors;
+
+ // currently we still hold a reference to it because we decided not to
+ // pass previous node argument to inline-processors (current usage is limited with NewLineInlineProcessor)
+ private Node block;
+ private String input;
+ private int index;
+
+ /**
+ * Top delimiter (emphasis, strong emphasis or custom emphasis). (Brackets are on a separate stack, different
+ * from the algorithm described in the spec.)
+ */
+ private Delimiter lastDelimiter;
+
+ /**
+ * Top opening bracket ([
or ![)
).
+ */
+ private Bracket lastBracket;
+
+ // might we construct these in factory?
+ public MarkwonInlineParser(
+ @NonNull InlineParserContext inlineParserContext,
+ boolean referencesEnabled,
+ @NonNull List inlineProcessors,
+ @NonNull List delimiterProcessors) {
+ this.inlineParserContext = inlineParserContext;
+ this.referencesEnabled = referencesEnabled;
+ this.inlineProcessors = calculateInlines(inlineProcessors);
+ this.delimiterProcessors = calculateDelimiterProcessors(delimiterProcessors);
+ this.specialCharacters = calculateSpecialCharacters(
+ this.inlineProcessors.keySet(),
+ this.delimiterProcessors.keySet());
+ }
+
+ @NonNull
+ private static Map> calculateInlines(@NonNull List inlines) {
+ final Map> map = new HashMap<>(inlines.size());
+ List list;
+ for (InlineProcessor inlineProcessor : inlines) {
+ final char character = inlineProcessor.specialCharacter();
+ list = map.get(character);
+ if (list == null) {
+ list = new ArrayList<>(1);
+ map.put(character, list);
+ }
+ list.add(inlineProcessor);
+ }
+ return map;
+ }
+
+ @NonNull
+ private static BitSet calculateSpecialCharacters(Set inlineCharacters, Set delimiterCharacters) {
+ final BitSet bitSet = new BitSet();
+ for (Character c : inlineCharacters) {
+ bitSet.set(c);
+ }
+ for (Character c : delimiterCharacters) {
+ bitSet.set(c);
+ }
+ return bitSet;
+ }
+
+ private static Map calculateDelimiterProcessors(List delimiterProcessors) {
+ Map map = new HashMap<>();
+ addDelimiterProcessors(delimiterProcessors, map);
+ return map;
+ }
+
+ private static void addDelimiterProcessors(Iterable delimiterProcessors, Map map) {
+ for (DelimiterProcessor delimiterProcessor : delimiterProcessors) {
+ char opening = delimiterProcessor.getOpeningCharacter();
+ char closing = delimiterProcessor.getClosingCharacter();
+ if (opening == closing) {
+ DelimiterProcessor old = map.get(opening);
+ if (old != null && old.getOpeningCharacter() == old.getClosingCharacter()) {
+ StaggeredDelimiterProcessor s;
+ if (old instanceof StaggeredDelimiterProcessor) {
+ s = (StaggeredDelimiterProcessor) old;
+ } else {
+ s = new StaggeredDelimiterProcessor(opening);
+ s.add(old);
+ }
+ s.add(delimiterProcessor);
+ map.put(opening, s);
+ } else {
+ addDelimiterProcessorForChar(opening, delimiterProcessor, map);
+ }
+ } else {
+ addDelimiterProcessorForChar(opening, delimiterProcessor, map);
+ addDelimiterProcessorForChar(closing, delimiterProcessor, map);
+ }
+ }
+ }
+
+ private static void addDelimiterProcessorForChar(char delimiterChar, DelimiterProcessor toAdd, Map delimiterProcessors) {
+ DelimiterProcessor existing = delimiterProcessors.put(delimiterChar, toAdd);
+ if (existing != null) {
+ throw new IllegalArgumentException("Delimiter processor conflict with delimiter char '" + delimiterChar + "'");
+ }
+ }
+
+ /**
+ * Parse content in block into inline children, using reference map to resolve references.
+ */
+ @Override
+ public void parse(String content, Node block) {
+ reset(content.trim());
+
+ // we still reference it
+ this.block = block;
+
+ while (true) {
+ Node node = parseInline();
+ if (node != null) {
+ block.appendChild(node);
+ } else {
+ break;
+ }
+ }
+
+ processDelimiters(null);
+ mergeChildTextNodes(block);
+ }
+
+ private void reset(String content) {
+ this.input = content;
+ this.index = 0;
+ this.lastDelimiter = null;
+ this.lastBracket = null;
+ }
+
+ /**
+ * Parse the next inline element in subject, advancing input index.
+ * On success, add the result to block's children and return true.
+ * On failure, return false.
+ */
+ @Nullable
+ private Node parseInline() {
+
+ final char c = peek();
+
+ if (c == '\0') {
+ return null;
+ }
+
+ Node node = null;
+
+ final List inlines = this.inlineProcessors.get(c);
+
+ if (inlines != null) {
+ for (InlineProcessor inline : inlines) {
+ node = inline.parse(this);
+ if (node != null) {
+ break;
+ }
+ }
+ } else {
+ final DelimiterProcessor delimiterProcessor = delimiterProcessors.get(c);
+ if (delimiterProcessor != null) {
+ node = parseDelimiters(delimiterProcessor, c);
+ } else {
+ node = parseString();
+ }
+ }
+
+ if (node != null) {
+ return node;
+ } else {
+ index++;
+ // When we get here, it's only for a single special character that turned out to not have a special meaning.
+ // So we shouldn't have a single surrogate here, hence it should be ok to turn it into a String.
+ String literal = String.valueOf(c);
+ return text(literal);
+ }
+ }
+
+ /**
+ * If RE matches at current index in the input, advance index and return the match; otherwise return null.
+ */
+ @Override
+ @Nullable
+ public String match(@NonNull Pattern re) {
+ if (index >= input.length()) {
+ return null;
+ }
+ Matcher matcher = re.matcher(input);
+ matcher.region(index, input.length());
+ boolean m = matcher.find();
+ if (m) {
+ index = matcher.end();
+ return matcher.group();
+ } else {
+ return null;
+ }
+ }
+
+ @NonNull
+ @Override
+ public Text text(@NonNull String text) {
+ return new Text(text);
+ }
+
+ @NonNull
+ @Override
+ public Text text(@NonNull String text, int beginIndex, int endIndex) {
+ return new Text(text.substring(beginIndex, endIndex));
+ }
+
+ @Nullable
+ @Override
+ public LinkReferenceDefinition getLinkReferenceDefinition(String label) {
+ return referencesEnabled
+ ? inlineParserContext.getLinkReferenceDefinition(label)
+ : null;
+ }
+
+ /**
+ * Returns the char at the current input index, or {@code '\0'} in case there are no more characters.
+ */
+ @Override
+ public char peek() {
+ if (index < input.length()) {
+ return input.charAt(index);
+ } else {
+ return '\0';
+ }
+ }
+
+ @NonNull
+ @Override
+ public Node block() {
+ return block;
+ }
+
+ @NonNull
+ @Override
+ public String input() {
+ return input;
+ }
+
+ @Override
+ public int index() {
+ return index;
+ }
+
+ @Override
+ public void setIndex(int index) {
+ this.index = index;
+ }
+
+ @Override
+ public Bracket lastBracket() {
+ return lastBracket;
+ }
+
+ @Override
+ public Delimiter lastDelimiter() {
+ return lastDelimiter;
+ }
+
+ @Override
+ public void addBracket(Bracket bracket) {
+ if (lastBracket != null) {
+ lastBracket.bracketAfter = true;
+ }
+ lastBracket = bracket;
+ }
+
+ @Override
+ public void removeLastBracket() {
+ lastBracket = lastBracket.previous;
+ }
+
+ /**
+ * Parse zero or more space characters, including at most one newline.
+ */
+ @Override
+ public void spnl() {
+ match(SPNL);
+ }
+
+ /**
+ * Attempt to parse delimiters like emphasis, strong emphasis or custom delimiters.
+ */
+ @Nullable
+ private Node parseDelimiters(DelimiterProcessor delimiterProcessor, char delimiterChar) {
+ DelimiterData res = scanDelimiters(delimiterProcessor, delimiterChar);
+ if (res == null) {
+ return null;
+ }
+ int length = res.count;
+ int startIndex = index;
+
+ index += length;
+ Text node = text(input, startIndex, index);
+
+ // Add entry to stack for this opener
+ lastDelimiter = new Delimiter(node, delimiterChar, res.canOpen, res.canClose, lastDelimiter);
+ lastDelimiter.length = length;
+ lastDelimiter.originalLength = length;
+ if (lastDelimiter.previous != null) {
+ lastDelimiter.previous.next = lastDelimiter;
+ }
+
+ return node;
+ }
+
+ /**
+ * Attempt to parse link destination, returning the string or null if no match.
+ */
+ @Override
+ @Nullable
+ public String parseLinkDestination() {
+ int afterDest = LinkScanner.scanLinkDestination(input, index);
+ if (afterDest == -1) {
+ return null;
+ }
+
+ String dest;
+ if (peek() == '<') {
+ // chop off surrounding <..>:
+ dest = input.substring(index + 1, afterDest - 1);
+ } else {
+ dest = input.substring(index, afterDest);
+ }
+
+ index = afterDest;
+ return Escaping.unescapeString(dest);
+ }
+
+ /**
+ * Attempt to parse link title (sans quotes), returning the string or null if no match.
+ */
+ @Override
+ @Nullable
+ public String parseLinkTitle() {
+ int afterTitle = LinkScanner.scanLinkTitle(input, index);
+ if (afterTitle == -1) {
+ return null;
+ }
+
+ // chop off ', " or parens
+ String title = input.substring(index + 1, afterTitle - 1);
+ index = afterTitle;
+ return Escaping.unescapeString(title);
+ }
+
+ /**
+ * Attempt to parse a link label, returning number of characters parsed.
+ */
+ @Override
+ public int parseLinkLabel() {
+ if (index >= input.length() || input.charAt(index) != '[') {
+ return 0;
+ }
+
+ int startContent = index + 1;
+ int endContent = LinkScanner.scanLinkLabelContent(input, startContent);
+ // spec: A link label can have at most 999 characters inside the square brackets.
+ int contentLength = endContent - startContent;
+ if (endContent == -1 || contentLength > 999) {
+ return 0;
+ }
+ if (endContent >= input.length() || input.charAt(endContent) != ']') {
+ return 0;
+ }
+ index = endContent + 1;
+ return contentLength + 2;
+ }
+
+ /**
+ * Parse a run of ordinary characters, or a single character with a special meaning in markdown, as a plain string.
+ */
+ private Node parseString() {
+ int begin = index;
+ int length = input.length();
+ while (index != length) {
+ if (specialCharacters.get(input.charAt(index))) {
+ break;
+ }
+ index++;
+ }
+ if (begin != index) {
+ return text(input, begin, index);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Scan a sequence of characters with code delimiterChar, and return information about the number of delimiters
+ * and whether they are positioned such that they can open and/or close emphasis or strong emphasis.
+ *
+ * @return information about delimiter run, or {@code null}
+ */
+ private DelimiterData scanDelimiters(DelimiterProcessor delimiterProcessor, char delimiterChar) {
+ int startIndex = index;
+
+ int delimiterCount = 0;
+ while (peek() == delimiterChar) {
+ delimiterCount++;
+ index++;
+ }
+
+ if (delimiterCount < delimiterProcessor.getMinLength()) {
+ index = startIndex;
+ return null;
+ }
+
+ String before = startIndex == 0 ? "\n" :
+ input.substring(startIndex - 1, startIndex);
+
+ char charAfter = peek();
+ String after = charAfter == '\0' ? "\n" :
+ String.valueOf(charAfter);
+
+ // We could be more lazy here, in most cases we don't need to do every match case.
+ boolean beforeIsPunctuation = PUNCTUATION.matcher(before).matches();
+ boolean beforeIsWhitespace = UNICODE_WHITESPACE_CHAR.matcher(before).matches();
+ boolean afterIsPunctuation = PUNCTUATION.matcher(after).matches();
+ boolean afterIsWhitespace = UNICODE_WHITESPACE_CHAR.matcher(after).matches();
+
+ boolean leftFlanking = !afterIsWhitespace &&
+ (!afterIsPunctuation || beforeIsWhitespace || beforeIsPunctuation);
+ boolean rightFlanking = !beforeIsWhitespace &&
+ (!beforeIsPunctuation || afterIsWhitespace || afterIsPunctuation);
+ boolean canOpen;
+ boolean canClose;
+ if (delimiterChar == '_') {
+ canOpen = leftFlanking && (!rightFlanking || beforeIsPunctuation);
+ canClose = rightFlanking && (!leftFlanking || afterIsPunctuation);
+ } else {
+ canOpen = leftFlanking && delimiterChar == delimiterProcessor.getOpeningCharacter();
+ canClose = rightFlanking && delimiterChar == delimiterProcessor.getClosingCharacter();
+ }
+
+ index = startIndex;
+ return new DelimiterData(delimiterCount, canOpen, canClose);
+ }
+
+ @Override
+ public void processDelimiters(Delimiter stackBottom) {
+
+ Map openersBottom = new HashMap<>();
+
+ // find first closer above stackBottom:
+ Delimiter closer = lastDelimiter;
+ while (closer != null && closer.previous != stackBottom) {
+ closer = closer.previous;
+ }
+ // move forward, looking for closers, and handling each
+ while (closer != null) {
+ char delimiterChar = closer.delimiterChar;
+
+ DelimiterProcessor delimiterProcessor = delimiterProcessors.get(delimiterChar);
+ if (!closer.canClose || delimiterProcessor == null) {
+ closer = closer.next;
+ continue;
+ }
+
+ char openingDelimiterChar = delimiterProcessor.getOpeningCharacter();
+
+ // Found delimiter closer. Now look back for first matching opener.
+ int useDelims = 0;
+ boolean openerFound = false;
+ boolean potentialOpenerFound = false;
+ Delimiter opener = closer.previous;
+ while (opener != null && opener != stackBottom && opener != openersBottom.get(delimiterChar)) {
+ if (opener.canOpen && opener.delimiterChar == openingDelimiterChar) {
+ potentialOpenerFound = true;
+ useDelims = delimiterProcessor.getDelimiterUse(opener, closer);
+ if (useDelims > 0) {
+ openerFound = true;
+ break;
+ }
+ }
+ opener = opener.previous;
+ }
+
+ if (!openerFound) {
+ if (!potentialOpenerFound) {
+ // Set lower bound for future searches for openers.
+ // Only do this when we didn't even have a potential
+ // opener (one that matches the character and can open).
+ // If an opener was rejected because of the number of
+ // delimiters (e.g. because of the "multiple of 3" rule),
+ // we want to consider it next time because the number
+ // of delimiters can change as we continue processing.
+ openersBottom.put(delimiterChar, closer.previous);
+ if (!closer.canOpen) {
+ // We can remove a closer that can't be an opener,
+ // once we've seen there's no matching opener:
+ removeDelimiterKeepNode(closer);
+ }
+ }
+ closer = closer.next;
+ continue;
+ }
+
+ Text openerNode = opener.node;
+ Text closerNode = closer.node;
+
+ // Remove number of used delimiters from stack and inline nodes.
+ opener.length -= useDelims;
+ closer.length -= useDelims;
+ openerNode.setLiteral(
+ openerNode.getLiteral().substring(0,
+ openerNode.getLiteral().length() - useDelims));
+ closerNode.setLiteral(
+ closerNode.getLiteral().substring(0,
+ closerNode.getLiteral().length() - useDelims));
+
+ removeDelimitersBetween(opener, closer);
+ // The delimiter processor can re-parent the nodes between opener and closer,
+ // so make sure they're contiguous already. Exclusive because we want to keep opener/closer themselves.
+ mergeTextNodesBetweenExclusive(openerNode, closerNode);
+ delimiterProcessor.process(openerNode, closerNode, useDelims);
+
+ // No delimiter characters left to process, so we can remove delimiter and the now empty node.
+ if (opener.length == 0) {
+ removeDelimiterAndNode(opener);
+ }
+
+ if (closer.length == 0) {
+ Delimiter next = closer.next;
+ removeDelimiterAndNode(closer);
+ closer = next;
+ }
+ }
+
+ // remove all delimiters
+ while (lastDelimiter != null && lastDelimiter != stackBottom) {
+ removeDelimiterKeepNode(lastDelimiter);
+ }
+ }
+
+ private void removeDelimitersBetween(Delimiter opener, Delimiter closer) {
+ Delimiter delimiter = closer.previous;
+ while (delimiter != null && delimiter != opener) {
+ Delimiter previousDelimiter = delimiter.previous;
+ removeDelimiterKeepNode(delimiter);
+ delimiter = previousDelimiter;
+ }
+ }
+
+ /**
+ * Remove the delimiter and the corresponding text node. For used delimiters, e.g. `*` in `*foo*`.
+ */
+ private void removeDelimiterAndNode(Delimiter delim) {
+ Text node = delim.node;
+ node.unlink();
+ removeDelimiter(delim);
+ }
+
+ /**
+ * Remove the delimiter but keep the corresponding node as text. For unused delimiters such as `_` in `foo_bar`.
+ */
+ private void removeDelimiterKeepNode(Delimiter delim) {
+ removeDelimiter(delim);
+ }
+
+ private void removeDelimiter(Delimiter delim) {
+ if (delim.previous != null) {
+ delim.previous.next = delim.next;
+ }
+ if (delim.next == null) {
+ // top of stack
+ lastDelimiter = delim.previous;
+ } else {
+ delim.next.previous = delim.previous;
+ }
+ }
+
+ private static class DelimiterData {
+
+ final int count;
+ final boolean canClose;
+ final boolean canOpen;
+
+ DelimiterData(int count, boolean canOpen, boolean canClose) {
+ this.count = count;
+ this.canOpen = canOpen;
+ this.canClose = canClose;
+ }
+ }
+
+ static class FactoryBuilderImpl implements FactoryBuilder, FactoryBuilderNoDefaults {
+
+ private final List inlineProcessors = new ArrayList<>(3);
+ private final List delimiterProcessors = new ArrayList<>(3);
+ private boolean referencesEnabled;
+
+ @NonNull
+ @Override
+ public FactoryBuilder addInlineProcessor(@NonNull InlineProcessor processor) {
+ this.inlineProcessors.add(processor);
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public FactoryBuilder addDelimiterProcessor(@NonNull DelimiterProcessor processor) {
+ this.delimiterProcessors.add(processor);
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public FactoryBuilder referencesEnabled(boolean referencesEnabled) {
+ this.referencesEnabled = referencesEnabled;
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public FactoryBuilder includeDefaults() {
+
+ // by default enabled
+ this.referencesEnabled = true;
+
+ this.inlineProcessors.addAll(Arrays.asList(
+ new AutolinkInlineProcessor(),
+ new BackslashInlineProcessor(),
+ new BackticksInlineProcessor(),
+ new BangInlineProcessor(),
+ new CloseBracketInlineProcessor(),
+ new EntityInlineProcessor(),
+ new HtmlInlineProcessor(),
+ new NewLineInlineProcessor(),
+ new OpenBracketInlineProcessor()));
+
+ this.delimiterProcessors.addAll(Arrays.asList(
+ new AsteriskDelimiterProcessor(),
+ new UnderscoreDelimiterProcessor()));
+
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public FactoryBuilder excludeInlineProcessor(@NonNull Class extends InlineProcessor> type) {
+ for (int i = 0, size = inlineProcessors.size(); i < size; i++) {
+ if (type.equals(inlineProcessors.get(i).getClass())) {
+ inlineProcessors.remove(i);
+ break;
+ }
+ }
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public FactoryBuilder excludeDelimiterProcessor(@NonNull Class extends DelimiterProcessor> type) {
+ for (int i = 0, size = delimiterProcessors.size(); i < size; i++) {
+ if (type.equals(delimiterProcessors.get(i).getClass())) {
+ delimiterProcessors.remove(i);
+ break;
+ }
+ }
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public InlineParserFactory build() {
+ return new InlineParserFactoryImpl(referencesEnabled, inlineProcessors, delimiterProcessors);
+ }
+ }
+
+ static class InlineParserFactoryImpl implements InlineParserFactory {
+
+ private final boolean referencesEnabled;
+ private final List inlineProcessors;
+ private final List delimiterProcessors;
+
+ InlineParserFactoryImpl(
+ boolean referencesEnabled,
+ @NonNull List inlineProcessors,
+ @NonNull List delimiterProcessors) {
+ this.referencesEnabled = referencesEnabled;
+ this.inlineProcessors = inlineProcessors;
+ this.delimiterProcessors = delimiterProcessors;
+ }
+
+ @Override
+ public InlineParser create(InlineParserContext inlineParserContext) {
+ final List delimiterProcessors;
+ final List customDelimiterProcessors = inlineParserContext.getCustomDelimiterProcessors();
+ final int size = customDelimiterProcessors != null
+ ? customDelimiterProcessors.size()
+ : 0;
+ if (size > 0) {
+ delimiterProcessors = new ArrayList<>(size + this.delimiterProcessors.size());
+ delimiterProcessors.addAll(this.delimiterProcessors);
+ delimiterProcessors.addAll(customDelimiterProcessors);
+ } else {
+ delimiterProcessors = this.delimiterProcessors;
+ }
+ return new MarkwonInlineParser(
+ inlineParserContext,
+ referencesEnabled,
+ inlineProcessors,
+ delimiterProcessors);
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserContext.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserContext.java
new file mode 100644
index 00000000..46870f91
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserContext.java
@@ -0,0 +1,64 @@
+package io.noties.markwon.inlineparser;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.Delimiter;
+import org.commonmark.node.Link;
+import org.commonmark.node.LinkReferenceDefinition;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+public interface MarkwonInlineParserContext {
+
+ @NonNull
+ Node block();
+
+ @NonNull
+ String input();
+
+ int index();
+
+ void setIndex(int index);
+
+ Bracket lastBracket();
+
+ Delimiter lastDelimiter();
+
+ void addBracket(Bracket bracket);
+
+ void removeLastBracket();
+
+ void spnl();
+
+ /**
+ * Returns the char at the current input index, or {@code '\0'} in case there are no more characters.
+ */
+ char peek();
+
+ @Nullable
+ String match(@NonNull Pattern re);
+
+ @NonNull
+ Text text(@NonNull String text);
+
+ @NonNull
+ Text text(@NonNull String text, int beginIndex, int endIndex);
+
+ @Nullable
+ LinkReferenceDefinition getLinkReferenceDefinition(String label);
+
+ @Nullable
+ String parseLinkDestination();
+
+ @Nullable
+ String parseLinkTitle();
+
+ int parseLinkLabel();
+
+ void processDelimiters(Delimiter stackBottom);
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/NewLineInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/NewLineInlineProcessor.java
new file mode 100644
index 00000000..ef978b72
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/NewLineInlineProcessor.java
@@ -0,0 +1,48 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.node.HardLineBreak;
+import org.commonmark.node.Node;
+import org.commonmark.node.SoftLineBreak;
+import org.commonmark.node.Text;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @since 4.2.0
+ */
+public class NewLineInlineProcessor extends InlineProcessor {
+
+ private static final Pattern FINAL_SPACE = Pattern.compile(" *$");
+
+ @Override
+ public char specialCharacter() {
+ return '\n';
+ }
+
+ @Override
+ protected Node parse() {
+ index++; // assume we're at a \n
+
+ final Node previous = block.getLastChild();
+
+ // Check previous text for trailing spaces.
+ // The "endsWith" is an optimization to avoid an RE match in the common case.
+ if (previous instanceof Text && ((Text) previous).getLiteral().endsWith(" ")) {
+ Text text = (Text) previous;
+ String literal = text.getLiteral();
+ Matcher matcher = FINAL_SPACE.matcher(literal);
+ int spaces = matcher.find() ? matcher.end() - matcher.start() : 0;
+ if (spaces > 0) {
+ text.setLiteral(literal.substring(0, literal.length() - spaces));
+ }
+ if (spaces >= 2) {
+ return new HardLineBreak();
+ } else {
+ return new SoftLineBreak();
+ }
+ } else {
+ return new SoftLineBreak();
+ }
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/OpenBracketInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/OpenBracketInlineProcessor.java
new file mode 100644
index 00000000..070d9ccc
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/OpenBracketInlineProcessor.java
@@ -0,0 +1,30 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.internal.Bracket;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+
+/**
+ * Parses markdown links {@code [link](#href)}
+ *
+ * @since 4.2.0
+ */
+public class OpenBracketInlineProcessor extends InlineProcessor {
+ @Override
+ public char specialCharacter() {
+ return '[';
+ }
+
+ @Override
+ protected Node parse() {
+ int startIndex = index;
+ index++;
+
+ Text node = text("[");
+
+ // Add entry to stack for this opener
+ addBracket(Bracket.link(node, startIndex, lastBracket(), lastDelimiter()));
+
+ return node;
+ }
+}
diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/StaggeredDelimiterProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/StaggeredDelimiterProcessor.java
new file mode 100644
index 00000000..c2a92c3d
--- /dev/null
+++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/StaggeredDelimiterProcessor.java
@@ -0,0 +1,75 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.node.Text;
+import org.commonmark.parser.delimiter.DelimiterProcessor;
+import org.commonmark.parser.delimiter.DelimiterRun;
+
+import java.util.LinkedList;
+import java.util.ListIterator;
+
+class StaggeredDelimiterProcessor implements DelimiterProcessor {
+
+ private final char delim;
+ private int minLength = 0;
+ private LinkedList processors = new LinkedList<>(); // in reverse getMinLength order
+
+ StaggeredDelimiterProcessor(char delim) {
+ this.delim = delim;
+ }
+
+ @Override
+ public char getOpeningCharacter() {
+ return delim;
+ }
+
+ @Override
+ public char getClosingCharacter() {
+ return delim;
+ }
+
+ @Override
+ public int getMinLength() {
+ return minLength;
+ }
+
+ void add(DelimiterProcessor dp) {
+ final int len = dp.getMinLength();
+ ListIterator it = processors.listIterator();
+ boolean added = false;
+ while (it.hasNext()) {
+ DelimiterProcessor p = it.next();
+ int pLen = p.getMinLength();
+ if (len > pLen) {
+ it.previous();
+ it.add(dp);
+ added = true;
+ break;
+ } else if (len == pLen) {
+ throw new IllegalArgumentException("Cannot add two delimiter processors for char '" + delim + "' and minimum length " + len);
+ }
+ }
+ if (!added) {
+ processors.add(dp);
+ this.minLength = len;
+ }
+ }
+
+ private DelimiterProcessor findProcessor(int len) {
+ for (DelimiterProcessor p : processors) {
+ if (p.getMinLength() <= len) {
+ return p;
+ }
+ }
+ return processors.getFirst();
+ }
+
+ @Override
+ public int getDelimiterUse(DelimiterRun opener, DelimiterRun closer) {
+ return findProcessor(opener.length()).getDelimiterUse(opener, closer);
+ }
+
+ @Override
+ public void process(Text opener, Text closer, int delimiterUse) {
+ findProcessor(delimiterUse).process(opener, closer, delimiterUse);
+ }
+}
diff --git a/markwon-inline-parser/src/test/java/io/noties/markwon/inlineparser/InlineParserSpecTest.java b/markwon-inline-parser/src/test/java/io/noties/markwon/inlineparser/InlineParserSpecTest.java
new file mode 100644
index 00000000..b7afb01d
--- /dev/null
+++ b/markwon-inline-parser/src/test/java/io/noties/markwon/inlineparser/InlineParserSpecTest.java
@@ -0,0 +1,25 @@
+package io.noties.markwon.inlineparser;
+
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.commonmark.testutil.SpecTestCase;
+import org.commonmark.testutil.example.Example;
+
+public class InlineParserSpecTest extends SpecTestCase {
+
+ private static final Parser PARSER = Parser.builder()
+ .inlineParserFactory(MarkwonInlineParser.factoryBuilder().build())
+ .build();
+
+ // The spec says URL-escaping is optional, but the examples assume that it's enabled.
+ private static final HtmlRenderer RENDERER = HtmlRenderer.builder().percentEncodeUrls(true).build();
+
+ public InlineParserSpecTest(Example example) {
+ super(example);
+ }
+
+ @Override
+ protected String render(String source) {
+ return RENDERER.render(PARSER.parse(source));
+ }
+}
diff --git a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java
index 292884bf..cb5c889e 100644
--- a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java
+++ b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java
@@ -1,18 +1,24 @@
package io.noties.markwon.linkify;
import android.text.SpannableStringBuilder;
+import android.text.style.URLSpan;
import android.text.util.Linkify;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
+import org.commonmark.node.Link;
+
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonVisitor;
+import io.noties.markwon.RenderProps;
+import io.noties.markwon.SpanFactory;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.core.CorePlugin;
+import io.noties.markwon.core.CoreProps;
public class LinkifyPlugin extends AbstractMarkwonPlugin {
@@ -55,34 +61,42 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin {
private static class LinkifyTextAddedListener implements CorePlugin.OnTextAddedListener {
private final int mask;
- private final SpannableStringBuilder builder;
LinkifyTextAddedListener(int mask) {
this.mask = mask;
- this.builder = new SpannableStringBuilder();
}
@Override
public void onTextAdded(@NonNull MarkwonVisitor visitor, @NonNull String text, int start) {
- // clear previous state
- builder.clear();
- builder.clearSpans();
+ // @since 4.2.0 obtain span factory for links
+ // we will be using the link that is used by markdown (instead of directly applying URLSpan)
+ final SpanFactory spanFactory = visitor.configuration().spansFactory().get(Link.class);
+ if (spanFactory == null) {
+ return;
+ }
- // append text to process
- builder.append(text);
+ // @since 4.2.0 we no longer re-use builder (thread safety achieved for
+ // render calls from different threads and ... better performance)
+ final SpannableStringBuilder builder = new SpannableStringBuilder(text);
if (Linkify.addLinks(builder, mask)) {
- final Object[] spans = builder.getSpans(0, builder.length(), Object.class);
+ // target URL span specifically
+ final URLSpan[] spans = builder.getSpans(0, builder.length(), URLSpan.class);
if (spans != null
&& spans.length > 0) {
+
+ final RenderProps renderProps = visitor.renderProps();
final SpannableBuilder spannableBuilder = visitor.builder();
- for (Object span : spans) {
- spannableBuilder.setSpan(
- span,
+
+ for (URLSpan span : spans) {
+ CoreProps.LINK_DESTINATION.set(renderProps, span.getURL());
+ SpannableBuilder.setSpans(
+ spannableBuilder,
+ spanFactory.getSpans(visitor.configuration(), renderProps),
start + builder.getSpanStart(span),
- start + builder.getSpanEnd(span),
- builder.getSpanFlags(span));
+ start + builder.getSpanEnd(span)
+ );
}
}
}
diff --git a/sample/build.gradle b/sample/build.gradle
index 51a912f4..d2a9e27f 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -34,16 +34,19 @@ android {
dependencies {
implementation project(':markwon-core')
+ implementation project(':markwon-editor')
implementation project(':markwon-ext-latex')
implementation project(':markwon-ext-strikethrough')
implementation project(':markwon-ext-tables')
implementation project(':markwon-ext-tasklist')
implementation project(':markwon-html')
implementation project(':markwon-image')
- implementation project(':markwon-syntax-highlight')
+ implementation project(':markwon-inline-parser')
+ implementation project(':markwon-linkify')
implementation project(':markwon-recycler')
implementation project(':markwon-recycler-table')
implementation project(':markwon-simple-ext')
+ implementation project(':markwon-syntax-highlight')
implementation project(':markwon-image-picasso')
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index 6492812f..ee887f8c 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -29,6 +29,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java
index a13427be..db937d19 100644
--- a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java
+++ b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java
@@ -22,7 +22,9 @@ import io.noties.markwon.sample.basicplugins.BasicPluginsActivity;
import io.noties.markwon.sample.core.CoreActivity;
import io.noties.markwon.sample.customextension.CustomExtensionActivity;
import io.noties.markwon.sample.customextension2.CustomExtensionActivity2;
+import io.noties.markwon.sample.editor.EditorActivity;
import io.noties.markwon.sample.html.HtmlActivity;
+import io.noties.markwon.sample.inlineparser.InlineParserActivity;
import io.noties.markwon.sample.latex.LatexActivity;
import io.noties.markwon.sample.precomputed.PrecomputedActivity;
import io.noties.markwon.sample.recycler.RecyclerActivity;
@@ -117,6 +119,14 @@ public class MainActivity extends Activity {
activity = PrecomputedActivity.class;
break;
+ case EDITOR:
+ activity = EditorActivity.class;
+ break;
+
+ case INLINE_PARSER:
+ activity = InlineParserActivity.class;
+ break;
+
default:
throw new IllegalStateException("No Activity is associated with sample-item: " + item);
}
diff --git a/sample/src/main/java/io/noties/markwon/sample/Sample.java b/sample/src/main/java/io/noties/markwon/sample/Sample.java
index 3102a1f2..221ee0bc 100644
--- a/sample/src/main/java/io/noties/markwon/sample/Sample.java
+++ b/sample/src/main/java/io/noties/markwon/sample/Sample.java
@@ -21,7 +21,11 @@ public enum Sample {
CUSTOM_EXTENSION_2(R.string.sample_custom_extension_2),
- PRECOMPUTED_TEXT(R.string.sample_precomputed_text);
+ PRECOMPUTED_TEXT(R.string.sample_precomputed_text),
+
+ EDITOR(R.string.sample_editor),
+
+ INLINE_PARSER(R.string.sample_inline_parser);
private final int textResId;
diff --git a/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java b/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java
index 735d6c0c..cb4f178f 100644
--- a/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java
+++ b/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java
@@ -8,6 +8,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.commonmark.node.Link;
+import org.commonmark.node.Node;
+import org.commonmark.parser.InlineParserFactory;
+import org.commonmark.parser.Parser;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -20,6 +23,8 @@ import io.noties.markwon.RenderProps;
import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.core.CorePlugin;
import io.noties.markwon.core.CoreProps;
+import io.noties.markwon.inlineparser.InlineProcessor;
+import io.noties.markwon.inlineparser.MarkwonInlineParser;
import io.noties.markwon.sample.R;
public class CustomExtensionActivity2 extends Activity {
@@ -35,6 +40,20 @@ public class CustomExtensionActivity2 extends Activity {
// * `#1` - an issue or a pull request
// * `@user` link to a user
+
+ final String md = "# Custom Extension 2\n" +
+ "\n" +
+ "This is an issue #1\n" +
+ "Done by @noties";
+
+
+// inline_parsing(textView, md);
+
+ text_added(textView, md);
+ }
+
+ private void text_added(@NonNull TextView textView, @NonNull String md) {
+
final Markwon markwon = Markwon.builder(this)
.usePlugin(new AbstractMarkwonPlugin() {
@Override
@@ -45,14 +64,83 @@ public class CustomExtensionActivity2 extends Activity {
})
.build();
- final String md = "# Custom Extension 2\n" +
- "\n" +
- "This is an issue #1\n" +
- "Done by @noties";
+ markwon.setMarkdown(textView, md);
+ }
+
+ private void inline_parsing(@NonNull TextView textView, @NonNull String md) {
+
+ final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder()
+ // include all current defaults (otherwise will be empty - contain only our inline-processors)
+ // included by default, to create factory-builder without defaults call `factoryBuilderNoDefaults`
+// .includeDefaults()
+ .addInlineProcessor(new IssueInlineProcessor())
+ .addInlineProcessor(new UserInlineProcessor())
+ .build();
+
+ final Markwon markwon = Markwon.builder(this)
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureParser(@NonNull Parser.Builder builder) {
+ builder.inlineParserFactory(inlineParserFactory);
+ }
+ })
+ .build();
markwon.setMarkdown(textView, md);
}
+ private static class IssueInlineProcessor extends InlineProcessor {
+
+ private static final Pattern RE = Pattern.compile("\\d+");
+
+ @Override
+ public char specialCharacter() {
+ return '#';
+ }
+
+ @Override
+ protected Node parse() {
+ final String id = match(RE);
+ if (id != null) {
+ final Link link = new Link(createIssueOrPullRequestLinkDestination(id), null);
+ link.appendChild(text("#" + id));
+ return link;
+ }
+ return null;
+ }
+
+ @NonNull
+ private static String createIssueOrPullRequestLinkDestination(@NonNull String id) {
+ return "https://github.com/noties/Markwon/issues/" + id;
+ }
+ }
+
+ private static class UserInlineProcessor extends InlineProcessor {
+
+ private static final Pattern RE = Pattern.compile("\\w+");
+
+ @Override
+ public char specialCharacter() {
+ return '@';
+ }
+
+ @Override
+ protected Node parse() {
+ final String user = match(RE);
+ if (user != null) {
+ final Link link = new Link(createUserLinkDestination(user), null);
+ link.appendChild(text("@" + user));
+ return link;
+ }
+ return null;
+ }
+
+ @NonNull
+ private static String createUserLinkDestination(@NonNull String user) {
+ return "https://github.com/" + user;
+ }
+ }
+
private static class GithubLinkifyRegexTextAddedListener implements CorePlugin.OnTextAddedListener {
private static final Pattern PATTERN = Pattern.compile("((#\\d+)|(@\\w+))", Pattern.MULTILINE);
diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java
new file mode 100644
index 00000000..704d40e3
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/editor/BlockQuoteEditHandler.java
@@ -0,0 +1,50 @@
+package io.noties.markwon.sample.editor;
+
+import android.text.Editable;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.Markwon;
+import io.noties.markwon.core.MarkwonTheme;
+import io.noties.markwon.core.spans.BlockQuoteSpan;
+import io.noties.markwon.editor.EditHandler;
+import io.noties.markwon.editor.PersistedSpans;
+
+class BlockQuoteEditHandler implements EditHandler {
+
+ private MarkwonTheme theme;
+
+ @Override
+ public void init(@NonNull Markwon markwon) {
+ this.theme = markwon.configuration().theme();
+ }
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(BlockQuoteSpan.class, () -> new BlockQuoteSpan(theme));
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull BlockQuoteSpan span,
+ int spanStart,
+ int spanTextLength) {
+ // todo: here we should actually find a proper ending of a block quote...
+ editable.setSpan(
+ persistedSpans.get(BlockQuoteSpan.class),
+ spanStart,
+ spanStart + spanTextLength,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return BlockQuoteSpan.class;
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java
new file mode 100644
index 00000000..c54e1a77
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/editor/CodeEditHandler.java
@@ -0,0 +1,54 @@
+package io.noties.markwon.sample.editor;
+
+import android.text.Editable;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.Markwon;
+import io.noties.markwon.core.MarkwonTheme;
+import io.noties.markwon.core.spans.CodeSpan;
+import io.noties.markwon.editor.EditHandler;
+import io.noties.markwon.editor.MarkwonEditorUtils;
+import io.noties.markwon.editor.PersistedSpans;
+
+class CodeEditHandler implements EditHandler {
+
+ private MarkwonTheme theme;
+
+ @Override
+ public void init(@NonNull Markwon markwon) {
+ this.theme = markwon.configuration().theme();
+ }
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(CodeSpan.class, () -> new CodeSpan(theme));
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull CodeSpan span,
+ int spanStart,
+ int spanTextLength) {
+ final MarkwonEditorUtils.Match match =
+ MarkwonEditorUtils.findDelimited(input, spanStart, "`");
+ if (match != null) {
+ editable.setSpan(
+ persistedSpans.get(CodeSpan.class),
+ match.start(),
+ match.end(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return CodeSpan.class;
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java
new file mode 100644
index 00000000..5553c9f8
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java
@@ -0,0 +1,330 @@
+package io.noties.markwon.sample.editor;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.method.LinkMovementMethod;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.MetricAffectingSpan;
+import android.text.style.StrikethroughSpan;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.parser.InlineParserFactory;
+import org.commonmark.parser.Parser;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executors;
+
+import io.noties.markwon.AbstractMarkwonPlugin;
+import io.noties.markwon.Markwon;
+import io.noties.markwon.core.spans.EmphasisSpan;
+import io.noties.markwon.core.spans.StrongEmphasisSpan;
+import io.noties.markwon.editor.AbstractEditHandler;
+import io.noties.markwon.editor.MarkwonEditor;
+import io.noties.markwon.editor.MarkwonEditorTextWatcher;
+import io.noties.markwon.editor.MarkwonEditorUtils;
+import io.noties.markwon.editor.PersistedSpans;
+import io.noties.markwon.editor.handler.EmphasisEditHandler;
+import io.noties.markwon.editor.handler.StrongEmphasisEditHandler;
+import io.noties.markwon.ext.strikethrough.StrikethroughPlugin;
+import io.noties.markwon.inlineparser.BangInlineProcessor;
+import io.noties.markwon.inlineparser.EntityInlineProcessor;
+import io.noties.markwon.inlineparser.HtmlInlineProcessor;
+import io.noties.markwon.inlineparser.MarkwonInlineParser;
+import io.noties.markwon.linkify.LinkifyPlugin;
+import io.noties.markwon.sample.R;
+
+public class EditorActivity extends Activity {
+
+ private EditText editText;
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_editor);
+
+ this.editText = findViewById(R.id.edit_text);
+ initBottomBar();
+
+// simple_process();
+
+// simple_pre_render();
+
+// custom_punctuation_span();
+
+// additional_edit_span();
+
+// additional_plugins();
+
+ multiple_edit_spans();
+ }
+
+ private void simple_process() {
+ // Process highlight in-place (right after text has changed)
+
+ // obtain Markwon instance
+ final Markwon markwon = Markwon.create(this);
+
+ // create editor
+ final MarkwonEditor editor = MarkwonEditor.create(markwon);
+
+ // set edit listener
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
+ }
+
+ private void simple_pre_render() {
+ // Process highlight in background thread
+
+ final Markwon markwon = Markwon.create(this);
+ final MarkwonEditor editor = MarkwonEditor.create(markwon);
+
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(
+ editor,
+ Executors.newCachedThreadPool(),
+ editText));
+ }
+
+ private void custom_punctuation_span() {
+ // Use own punctuation span
+
+ final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
+ .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new)
+ .build();
+
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
+ }
+
+ private void additional_edit_span() {
+ // An additional span is used to highlight strong-emphasis
+
+ final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
+ .useEditHandler(new AbstractEditHandler() {
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ // Here we define which span is _persisted_ in EditText, it is not removed
+ // from EditText between text changes, but instead - reused (by changing
+ // position). Consider it as a cache for spans. We could use `StrongEmphasisSpan`
+ // here also, but I chose Bold to indicate that this span is not the same
+ // as in off-screen rendered markdown
+ builder.persistSpan(Bold.class, Bold::new);
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull StrongEmphasisSpan span,
+ int spanStart,
+ int spanTextLength) {
+ // Unfortunately we cannot hardcode delimiters length here (aka spanTextLength + 4)
+ // because multiple inline markdown nodes can refer to the same text.
+ // For example, `**_~~hey~~_**` - we will receive `**_~~` in this method,
+ // and thus will have to manually find actual position in raw user input
+ final MarkwonEditorUtils.Match match =
+ MarkwonEditorUtils.findDelimited(input, spanStart, "**", "__");
+ if (match != null) {
+ editable.setSpan(
+ // we handle StrongEmphasisSpan and represent it with Bold in EditText
+ // we still could use StrongEmphasisSpan, but it must be accessed
+ // via persistedSpans
+ persistedSpans.get(Bold.class),
+ match.start(),
+ match.end(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return StrongEmphasisSpan.class;
+ }
+ })
+ .build();
+
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
+ }
+
+ private void additional_plugins() {
+ // As highlight works based on text-diff, everything that is present in input,
+ // but missing in resulting markdown is considered to be punctuation, this is why
+ // additional plugins do not need special handling
+
+ final Markwon markwon = Markwon.builder(this)
+ .usePlugin(StrikethroughPlugin.create())
+ .build();
+
+ final MarkwonEditor editor = MarkwonEditor.create(markwon);
+
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
+ }
+
+ private void multiple_edit_spans() {
+
+ // for links to be clickable
+ editText.setMovementMethod(LinkMovementMethod.getInstance());
+
+ final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder()
+ // no inline images will be parsed
+ .excludeInlineProcessor(BangInlineProcessor.class)
+ // no html tags will be parsed
+ .excludeInlineProcessor(HtmlInlineProcessor.class)
+ // no entities will be parsed (aka `&` etc)
+ .excludeInlineProcessor(EntityInlineProcessor.class)
+ .build();
+
+ final Markwon markwon = Markwon.builder(this)
+ .usePlugin(StrikethroughPlugin.create())
+ .usePlugin(LinkifyPlugin.create())
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureParser(@NonNull Parser.Builder builder) {
+
+ // disable all commonmark-java blocks, only inlines will be parsed
+// builder.enabledBlockTypes(Collections.emptySet());
+
+ builder.inlineParserFactory(inlineParserFactory);
+ }
+ })
+ .build();
+
+ final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link);
+
+ final MarkwonEditor editor = MarkwonEditor.builder(markwon)
+ .useEditHandler(new EmphasisEditHandler())
+ .useEditHandler(new StrongEmphasisEditHandler())
+ .useEditHandler(new StrikethroughEditHandler())
+ .useEditHandler(new CodeEditHandler())
+ .useEditHandler(new BlockQuoteEditHandler())
+ .useEditHandler(new LinkEditHandler(onClick))
+ .build();
+
+// editText.addTextChangedListener(MarkwonEditorTextWatcher.withProcess(editor));
+ editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(
+ editor, Executors.newSingleThreadExecutor(), editText));
+ }
+
+ private void initBottomBar() {
+ // all except block-quote wraps if have selection, or inserts at current cursor position
+
+ final Button bold = findViewById(R.id.bold);
+ final Button italic = findViewById(R.id.italic);
+ final Button strike = findViewById(R.id.strike);
+ final Button quote = findViewById(R.id.quote);
+ final Button code = findViewById(R.id.code);
+
+ addSpan(bold, new StrongEmphasisSpan());
+ addSpan(italic, new EmphasisSpan());
+ addSpan(strike, new StrikethroughSpan());
+
+ bold.setOnClickListener(new InsertOrWrapClickListener(editText, "**"));
+ italic.setOnClickListener(new InsertOrWrapClickListener(editText, "_"));
+ strike.setOnClickListener(new InsertOrWrapClickListener(editText, "~~"));
+ code.setOnClickListener(new InsertOrWrapClickListener(editText, "`"));
+
+ quote.setOnClickListener(v -> {
+
+ final int start = editText.getSelectionStart();
+ final int end = editText.getSelectionEnd();
+
+ if (start < 0) {
+ return;
+ }
+
+ if (start == end) {
+ editText.getText().insert(start, "> ");
+ } else {
+ // wrap the whole selected area in a quote
+ final List newLines = new ArrayList<>(3);
+ newLines.add(start);
+
+ final String text = editText.getText().subSequence(start, end).toString();
+ int index = text.indexOf('\n');
+ while (index != -1) {
+ newLines.add(start + index + 1);
+ index = text.indexOf('\n', index + 1);
+ }
+
+ for (int i = newLines.size() - 1; i >= 0; i--) {
+ editText.getText().insert(newLines.get(i), "> ");
+ }
+ }
+ });
+ }
+
+ private static void addSpan(@NonNull TextView textView, Object... spans) {
+ final SpannableStringBuilder builder = new SpannableStringBuilder(textView.getText());
+ final int end = builder.length();
+ for (Object span : spans) {
+ builder.setSpan(span, 0, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ textView.setText(builder);
+ }
+
+ private static class InsertOrWrapClickListener implements View.OnClickListener {
+
+ private final EditText editText;
+ private final String text;
+
+ InsertOrWrapClickListener(@NonNull EditText editText, @NonNull String text) {
+ this.editText = editText;
+ this.text = text;
+ }
+
+ @Override
+ public void onClick(View v) {
+ final int start = editText.getSelectionStart();
+ final int end = editText.getSelectionEnd();
+
+ if (start < 0) {
+ return;
+ }
+
+ if (start == end) {
+ // insert at current position
+ editText.getText().insert(start, text);
+ } else {
+ editText.getText().insert(end, text);
+ editText.getText().insert(start, text);
+ }
+ }
+ }
+
+ private static class CustomPunctuationSpan extends ForegroundColorSpan {
+ CustomPunctuationSpan() {
+ super(0xFFFF0000); // RED
+ }
+ }
+
+ private static class Bold extends MetricAffectingSpan {
+ public Bold() {
+ super();
+ }
+
+ @Override
+ public void updateDrawState(TextPaint tp) {
+ update(tp);
+ }
+
+ @Override
+ public void updateMeasureState(@NonNull TextPaint textPaint) {
+ update(textPaint);
+ }
+
+ private void update(@NonNull TextPaint paint) {
+ paint.setFakeBoldText(true);
+ }
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java
new file mode 100644
index 00000000..743428d0
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java
@@ -0,0 +1,86 @@
+package io.noties.markwon.sample.editor;
+
+import android.text.Editable;
+import android.text.Spanned;
+import android.text.style.ClickableSpan;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.core.spans.LinkSpan;
+import io.noties.markwon.editor.AbstractEditHandler;
+import io.noties.markwon.editor.PersistedSpans;
+
+class LinkEditHandler extends AbstractEditHandler {
+
+ interface OnClick {
+ void onClick(@NonNull View widget, @NonNull String link);
+ }
+
+ private final OnClick onClick;
+
+ LinkEditHandler(@NonNull OnClick onClick) {
+ this.onClick = onClick;
+ }
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(EditLinkSpan.class, () -> new EditLinkSpan(onClick));
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull LinkSpan span,
+ int spanStart,
+ int spanTextLength) {
+
+ final EditLinkSpan editLinkSpan = persistedSpans.get(EditLinkSpan.class);
+ editLinkSpan.link = span.getLink();
+
+ final int s;
+ final int e;
+
+ // markdown link vs. autolink
+ if ('[' == input.charAt(spanStart)) {
+ s = spanStart + 1;
+ e = spanStart + 1 + spanTextLength;
+ } else {
+ s = spanStart;
+ e = spanStart + spanTextLength;
+ }
+
+ editable.setSpan(
+ editLinkSpan,
+ s,
+ e,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return LinkSpan.class;
+ }
+
+ static class EditLinkSpan extends ClickableSpan {
+
+ private final OnClick onClick;
+
+ String link;
+
+ EditLinkSpan(@NonNull OnClick onClick) {
+ this.onClick = onClick;
+ }
+
+ @Override
+ public void onClick(@NonNull View widget) {
+ if (link != null) {
+ onClick.onClick(widget, link);
+ }
+ }
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java
new file mode 100644
index 00000000..bffda27b
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/editor/StrikethroughEditHandler.java
@@ -0,0 +1,45 @@
+package io.noties.markwon.sample.editor;
+
+import android.text.Editable;
+import android.text.Spanned;
+import android.text.style.StrikethroughSpan;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.editor.AbstractEditHandler;
+import io.noties.markwon.editor.MarkwonEditorUtils;
+import io.noties.markwon.editor.PersistedSpans;
+
+class StrikethroughEditHandler extends AbstractEditHandler {
+
+ @Override
+ public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+ builder.persistSpan(StrikethroughSpan.class, StrikethroughSpan::new);
+ }
+
+ @Override
+ public void handleMarkdownSpan(
+ @NonNull PersistedSpans persistedSpans,
+ @NonNull Editable editable,
+ @NonNull String input,
+ @NonNull StrikethroughSpan span,
+ int spanStart,
+ int spanTextLength) {
+ final MarkwonEditorUtils.Match match =
+ MarkwonEditorUtils.findDelimited(input, spanStart, "~~");
+ if (match != null) {
+ editable.setSpan(
+ persistedSpans.get(StrikethroughSpan.class),
+ match.start(),
+ match.end(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+ );
+ }
+ }
+
+ @NonNull
+ @Override
+ public Class markdownSpanType() {
+ return StrikethroughSpan.class;
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java
new file mode 100644
index 00000000..27d069eb
--- /dev/null
+++ b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java
@@ -0,0 +1,118 @@
+package io.noties.markwon.sample.inlineparser;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.commonmark.node.Block;
+import org.commonmark.node.BlockQuote;
+import org.commonmark.node.Heading;
+import org.commonmark.node.HtmlBlock;
+import org.commonmark.node.ListBlock;
+import org.commonmark.node.ThematicBreak;
+import org.commonmark.parser.InlineParserFactory;
+import org.commonmark.parser.Parser;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import io.noties.markwon.AbstractMarkwonPlugin;
+import io.noties.markwon.Markwon;
+import io.noties.markwon.inlineparser.BackticksInlineProcessor;
+import io.noties.markwon.inlineparser.CloseBracketInlineProcessor;
+import io.noties.markwon.inlineparser.MarkwonInlineParser;
+import io.noties.markwon.inlineparser.OpenBracketInlineProcessor;
+import io.noties.markwon.sample.R;
+
+public class InlineParserActivity extends Activity {
+
+ private TextView textView;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_text_view);
+
+ this.textView = findViewById(R.id.text_view);
+
+// links_only();
+
+ disable_code();
+ }
+
+ private void links_only() {
+
+ // create an inline-parser-factory that will _ONLY_ parse links
+ // this would mean:
+ // * no emphasises (strong and regular aka bold and italics),
+ // * no images,
+ // * no code,
+ // * no HTML entities (&)
+ // * no HTML tags
+ // markdown blocks are still parsed
+ final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilderNoDefaults()
+ .referencesEnabled(true)
+ .addInlineProcessor(new OpenBracketInlineProcessor())
+ .addInlineProcessor(new CloseBracketInlineProcessor())
+ .build();
+
+ final Markwon markwon = Markwon.builder(this)
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureParser(@NonNull Parser.Builder builder) {
+ builder.inlineParserFactory(inlineParserFactory);
+ }
+ })
+ .build();
+
+ // note that image is considered a link now
+ final String md = "**bold_bold-italic_** html-u , [link](#)  `code`";
+ markwon.setMarkdown(textView, md);
+ }
+
+ private void disable_code() {
+ // parses all as usual, but ignores code (inline and block)
+
+ final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder()
+ .excludeInlineProcessor(BackticksInlineProcessor.class)
+ .build();
+
+ // unfortunately there is no _exclude_ method for parser-builder
+ final Set> enabledBlocks = new HashSet>() {{
+ // IndentedCodeBlock.class and FencedCodeBlock.class are missing
+ // this is full list (including above) that can be passed to `enabledBlockTypes` method
+ addAll(Arrays.asList(
+ BlockQuote.class,
+ Heading.class,
+ HtmlBlock.class,
+ ThematicBreak.class,
+ ListBlock.class));
+ }};
+
+ final Markwon markwon = Markwon.builder(this)
+ .usePlugin(new AbstractMarkwonPlugin() {
+ @Override
+ public void configureParser(@NonNull Parser.Builder builder) {
+ builder
+ .inlineParserFactory(inlineParserFactory)
+ .enabledBlockTypes(enabledBlocks);
+ }
+ })
+ .build();
+
+ final String md = "# Head!\n\n" +
+ "* one\n" +
+ "+ two\n\n" +
+ "and **bold** to `you`!\n\n" +
+ "> a quote _em_\n\n" +
+ "```java\n" +
+ "final int i = 0;\n" +
+ "```\n\n" +
+ "**Good day!**";
+ markwon.setMarkdown(textView, md);
+ }
+}
diff --git a/sample/src/main/java/io/noties/markwon/sample/recycler/RecyclerActivity.java b/sample/src/main/java/io/noties/markwon/sample/recycler/RecyclerActivity.java
index bbd6f890..815e5b03 100644
--- a/sample/src/main/java/io/noties/markwon/sample/recycler/RecyclerActivity.java
+++ b/sample/src/main/java/io/noties/markwon/sample/recycler/RecyclerActivity.java
@@ -82,6 +82,7 @@ public class RecyclerActivity extends Activity {
// }))
.usePlugin(PicassoImagesPlugin.create(context))
// .usePlugin(GlideImagesPlugin.create(context))
+// .usePlugin(CoilImagesPlugin.create(context))
// important to use TableEntryPlugin instead of TablePlugin
.usePlugin(TableEntryPlugin.create(context))
.usePlugin(HtmlPlugin.create())
diff --git a/sample/src/main/res/layout/activity_editor.xml b/sample/src/main/res/layout/activity_editor.xml
new file mode 100644
index 00000000..c401a8cb
--- /dev/null
+++ b/sample/src/main/res/layout/activity_editor.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sample/src/main/res/values/strings-samples.xml b/sample/src/main/res/values/strings-samples.xml
index b2fc98d2..a26f62c5 100644
--- a/sample/src/main/res/values/strings-samples.xml
+++ b/sample/src/main/res/values/strings-samples.xml
@@ -25,4 +25,8 @@
# \# PrecomputedText\n\nUsage of TextSetter and PrecomputedTextCompat
+ # \# Editor\n\n`MarkwonEditor` sample usage to highlight user input in EditText
+
+ # \# Inline Parser\n\nUsage of custom inline parser
+
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 45a92d52..8bf10dcb 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,14 +1,17 @@
rootProject.name = 'MarkwonProject'
include ':app', ':sample',
':markwon-core',
+ ':markwon-editor',
':markwon-ext-latex',
':markwon-ext-strikethrough',
':markwon-ext-tables',
':markwon-ext-tasklist',
':markwon-html',
':markwon-image',
+ ':markwon-image-coil',
':markwon-image-glide',
':markwon-image-picasso',
+ ':markwon-inline-parser',
':markwon-linkify',
':markwon-recycler',
':markwon-recycler-table',