diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
index 968f2a92..dfb474a6 100644
--- a/.github/workflows/develop.yml
+++ b/.github/workflows/develop.yml
@@ -15,7 +15,7 @@ jobs:
         with:
           java-version: 1.8
       - name: Build with Gradle
-        run: ./gradlew build
+        run: ./gradlew build -Prelease
 
   deploy:
     needs: build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5286ff9b..1910f583 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,24 @@
 # Changelog
 
+# 4.2.0
+* `MarkwonEditor` to highlight markdown input whilst editing (new module: `markwon-editor`)
+* `CoilImagesPlugin` image loader based on [Coil] library (new module: `markwon-image-coil`) ([#166], [#174])
+<br>Thanks to [@tylerbwong]
+* `MarkwonInlineParser` to customize inline parsing (new module: `markwon-inline-parser`)
+* Update commonmark-java to `0.13.0` (and commonmark spec `0.29`)
+* `Markwon#configuration` method to expose `MarkwonConfiguration` via public API
+* `HeadingSpan#getLevel` getter
+* Add `SvgPictureMediaDecoder` in `image` module to deal with SVG without dimensions ([#165])
+* `LinkSpan#getLink` method
+* `LinkifyPlugin` applies link span that is configured by `Markwon` (obtain via span factory)
+* `LinkifyPlugin` is thread-safe
+
+[@tylerbwong]: https://github.com/tylerbwong
+[Coil]: https://github.com/coil-kt/coil
+[#165]: https://github.com/noties/Markwon/issues/165
+[#166]: https://github.com/noties/Markwon/issues/166
+[#174]: https://github.com/noties/Markwon/pull/174
+
 # 4.1.2
 * Do not re-use RenderProps when creating a new visitor (fixes [#171])
 
diff --git a/README.md b/README.md
index d053f8c2..594d9781 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,9 @@ features listed in [commonmark-spec] are supported
 (including support for **inlined/block HTML code**, **markdown tables**,
 **images** and **syntax highlight**).
 
+Since version **4.2.0** **Markwon** comes with an [editor](./markwon-editor/) to _highlight_ markdown input
+as user types (for example in **EditText**).
+
 [commonmark-spec]: https://spec.commonmark.org/0.28/
 [commonmark-java]: https://github.com/atlassian/commonmark-java/blob/master/README.md
 
diff --git a/app/build.gradle b/app/build.gradle
index 33d7c8cd..aba0e620 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -17,13 +17,6 @@ android {
     lintOptions {
         abortOnError false
     }
-
-    buildTypes {
-        debug {
-            minifyEnabled false
-            proguardFile 'proguard.pro'
-        }
-    }
 }
 
 dependencies {
diff --git a/build.gradle b/build.gradle
index 9f065c7a..4bb3392e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,7 +4,7 @@ buildscript {
         jcenter()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:3.5.1'
+        classpath 'com.android.tools.build:gradle:3.5.2'
         classpath 'com.github.ben-manes:gradle-versions-plugin:0.21.0'
     }
 }
@@ -44,7 +44,6 @@ if (hasProperty('local')) {
 
 ext {
 
-    // NB, updating build-tools or compile-sdk will require updating Travis config (.travis.yml)
     config = [
             'build-tools'    : '28.0.3',
             'compile-sdk'    : 28,
@@ -53,7 +52,7 @@ ext {
             'push-aar-gradle': 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
     ]
 
-    final def commonMarkVersion = '0.12.1'
+    final def commonMarkVersion = '0.13.0'
     final def daggerVersion = '2.10'
 
     deps = [
@@ -72,7 +71,8 @@ ext {
             'adapt'                   : 'io.noties:adapt:2.0.0',
             'dagger'                  : "com.google.dagger:dagger:$daggerVersion",
             'picasso'                 : 'com.squareup.picasso:picasso:2.71828',
-            'glide'                   : 'com.github.bumptech.glide:glide:4.9.0'
+            'glide'                   : 'com.github.bumptech.glide:glide:4.9.0',
+            'coil'                    : 'io.coil-kt:coil:0.8.0'
     ]
 
     deps['annotationProcessor'] = [
@@ -81,11 +81,12 @@ ext {
     ]
 
     deps['test'] = [
-            'junit'      : 'junit:junit:4.12',
-            'robolectric': 'org.robolectric:robolectric:3.8',
-            'ix-java'    : 'com.github.akarnokd:ixjava:1.0.0',
-            'commons-io' : 'commons-io:commons-io:2.6',
-            'mockito'    : 'org.mockito:mockito-core:2.21.0'
+            'junit'               : 'junit:junit:4.12',
+            'robolectric'         : 'org.robolectric:robolectric:3.8',
+            'ix-java'             : 'com.github.akarnokd:ixjava:1.0.0',
+            'commons-io'          : 'commons-io:commons-io:2.6',
+            'mockito'             : 'org.mockito:mockito-core:2.21.0',
+            'commonmark-test-util': "com.atlassian.commonmark:commonmark-test-util:$commonMarkVersion",
     ]
 
     registerArtifact = this.&registerArtifact
diff --git a/docs/.vuepress/.artifacts.js b/docs/.vuepress/.artifacts.js
index 62c52ab8..a9880b7d 100644
--- a/docs/.vuepress/.artifacts.js
+++ b/docs/.vuepress/.artifacts.js
@@ -1,4 +1,4 @@
 
 // this is a generated file, do not modify. To update it run 'collectArtifacts.js' script
-const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}];
+const artifacts = [{"id":"core","name":"Core","group":"io.noties.markwon","description":"Core Markwon artifact that includes basic markdown parsing and rendering"},{"id":"editor","name":"Editor","group":"io.noties.markwon","description":"Markdown editor based on Markwon"},{"id":"ext-latex","name":"LaTeX","group":"io.noties.markwon","description":"Extension to add LaTeX formulas to Markwon markdown"},{"id":"ext-strikethrough","name":"Strikethrough","group":"io.noties.markwon","description":"Extension to add strikethrough markup to Markwon markdown"},{"id":"ext-tables","name":"Tables","group":"io.noties.markwon","description":"Extension to add tables markup (GFM) to Markwon markdown"},{"id":"ext-tasklist","name":"Task List","group":"io.noties.markwon","description":"Extension to add task lists (GFM) to Markwon markdown"},{"id":"html","name":"HTML","group":"io.noties.markwon","description":"Provides HTML parsing functionality"},{"id":"image","name":"Image","group":"io.noties.markwon","description":"Markwon image loading module (with optional GIF and SVG support)"},{"id":"image-coil","name":"Image Coil","group":"io.noties.markwon","description":"Markwon image loading module (based on Coil library)"},{"id":"image-glide","name":"Image Glide","group":"io.noties.markwon","description":"Markwon image loading module (based on Glide library)"},{"id":"image-picasso","name":"Image Picasso","group":"io.noties.markwon","description":"Markwon image loading module (based on Picasso library)"},{"id":"inline-parser","name":"Inline Parser","group":"io.noties.markwon","description":"Markwon customizable commonmark-java InlineParser"},{"id":"linkify","name":"Linkify","group":"io.noties.markwon","description":"Markwon plugin to linkify text (based on Android Linkify)"},{"id":"recycler","name":"Recycler","group":"io.noties.markwon","description":"Provides RecyclerView.Adapter to display Markwon markdown"},{"id":"recycler-table","name":"Recycler Table","group":"io.noties.markwon","description":"Provides MarkwonAdapter.Entry to render TableBlocks inside Android-native TableLayout widget"},{"id":"simple-ext","name":"Simple Extension","group":"io.noties.markwon","description":"Custom extension based on simple delimiter usage"},{"id":"syntax-highlight","name":"Syntax Highlight","group":"io.noties.markwon","description":"Add syntax highlight to Markwon markdown via Prism4j library"}];
 export { artifacts };
diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js
index cd58b64c..32d0cd9a 100644
--- a/docs/.vuepress/config.js
+++ b/docs/.vuepress/config.js
@@ -95,14 +95,17 @@ module.exports = {
                         '/docs/v4/core/text-setter.md'
                     ]
                 },
+                '/docs/v4/editor/',
                 '/docs/v4/ext-latex/',
                 '/docs/v4/ext-strikethrough/',
                 '/docs/v4/ext-tables/',
                 '/docs/v4/ext-tasklist/',
                 '/docs/v4/html/',
                 '/docs/v4/image/',
+                '/docs/v4/image-coil/',
                 '/docs/v4/image-glide/',
                 '/docs/v4/image-picasso/',
+                '/docs/v4/inline-parser/',
                 '/docs/v4/linkify/',
                 '/docs/v4/recycler/',
                 '/docs/v4/recycler-table/',
diff --git a/docs/.vuepress/public/assets/markwon-editor-preview.jpg b/docs/.vuepress/public/assets/markwon-editor-preview.jpg
new file mode 100644
index 00000000..e5b29e05
Binary files /dev/null and b/docs/.vuepress/public/assets/markwon-editor-preview.jpg differ
diff --git a/docs/.vuepress/public/assets/markwon-editor.mp4 b/docs/.vuepress/public/assets/markwon-editor.mp4
new file mode 100644
index 00000000..8ce65a68
Binary files /dev/null and b/docs/.vuepress/public/assets/markwon-editor.mp4 differ
diff --git a/docs/README.md b/docs/README.md
index abab2311..9f53dca3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -21,6 +21,11 @@ but also gives all the means to tweak the appearance if desired. All markdown fe
 listed in <Link name="commonmark-spec" /> are supported (including support for **inlined/block HTML code**, 
 **markdown tables**, **images** and **syntax highlight**).
 
+Since version <Badge text="4.2.0" /> **Markwon** comes with an [editor] to _highlight_ markdown input
+as user types (for example in **EditText**).
+
+[editor]: /docs/v4/editor/
+
 ## Supported markdown features
 
 * Emphasis (`*`, `_`)
diff --git a/docs/docs/v4/editor/README.md b/docs/docs/v4/editor/README.md
new file mode 100644
index 00000000..7cd086e2
--- /dev/null
+++ b/docs/docs/v4/editor/README.md
@@ -0,0 +1,150 @@
+# Editor <Badge text="4.2.0" />
+
+<MavenBadge4 :artifact="'editor'" />
+
+Markdown editing highlight for Android based on **Markwon**.
+
+<style>
+video {
+    max-height: 82vh;
+}
+</style>
+
+<video controls="true" loop="" :poster="$withBase('/assets/markwon-editor-preview.jpg')">
+    <source :src="$withBase('/assets/markwon-editor.mp4')" type="video/mp4">
+    You browser does not support mp4 playback, try downloading video file 
+    <a :href="$withBase('/assets/markwon-editor.mp4')">directly</a>
+</video>
+
+## Getting started with editor
+
+```java
+// 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));
+```
+
+The code above _highlights_ in-place which is OK for relatively small markdown inputs.
+If you wish to offload main thread and highlight in background use `withPreRender`
+`MarkwonEditorTextWatcher`:
+
+```java
+editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(
+        editor,
+        Executors.newCachedThreadPool(),
+        editText));
+```
+
+`MarkwonEditorTextWatcher` automatically triggers markdown highlight when text in `EditText` changes.
+But you still can invoke `MarkwonEditor` manually:
+
+```java
+editor.process(editText.getText());
+
+// please note that MarkwonEditor operates on caller thread,
+// if you wish to execute this operation in background - this method
+// must be called from background thread
+editor.preRender(editText.getText(), new MarkwonEditor.PreRenderResultListener() {
+    @Override
+    public void onPreRenderResult(@NonNull MarkwonEditor.PreRenderResult result) {
+        // it's wise to check if rendered result is for the same input,
+        // for example by matching raw input
+        if (editText.getText().toString().equals(result.resultEditable().toString())) {
+            
+            // if you are in background thread do not forget
+            // to execute dispatch in main thread
+            result.dispatchTo(editText.getText());
+        }
+    }
+});
+```
+
+:::warning Implementation Detail
+It must be mentioned that highlight is implemented via text diff. Everything
+that is present in raw markdown input but missing from rendered result is considered
+to be _punctuation_.
+:::
+
+:::danger Tables and LaTeX
+Tables and LaTeX nodes won't be rendered correctly. They will be treated as _punctuation_
+as whole. This comes from their implementation - they are _mocked_ and do not present
+in final result as text and thus cannot be _diffed_. 
+:::
+
+## Custom punctuation span
+
+By default `MarkwonEditor` uses lighter text color of widget to customize punctuation.
+If you wish to use a different span you can use `punctuationSpan` configuration step:
+
+```java
+final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
+        .punctuationSpan(CustomPunctuationSpan.class, CustomPunctuationSpan::new)
+        .build();
+```
+
+```java
+public class CustomPunctuationSpan extends ForegroundColorSpan {
+    CustomPunctuationSpan() {
+        super(0xFFFF0000); // RED
+    }
+}
+```
+
+## Additional handling
+
+In order to additionally highlight portions of markdown input (for example make text wrapped with `**`
+symbols **bold**) `EditHandler` can be used:
+
+```java
+final MarkwonEditor editor = MarkwonEditor.builder(Markwon.create(this))
+        .useEditHandler(new AbstractEditHandler<StrongEmphasisSpan>() {
+            @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<StrongEmphasisSpan> markdownSpanType() {
+                return StrongEmphasisSpan.class;
+            }
+        })
+        .build();
+```
diff --git a/docs/docs/v4/image-coil/README.md b/docs/docs/v4/image-coil/README.md
new file mode 100644
index 00000000..5227ed7b
--- /dev/null
+++ b/docs/docs/v4/image-coil/README.md
@@ -0,0 +1,35 @@
+# Image Coil
+
+<MavenBadge4 :artifact="'image-coil'" />
+
+Image loading based on `Coil` library
+
+```kotlin
+val markwon = Markwon.builder(context)
+        // automatically create Coil instance
+        .usePlugin(CoilImagesPlugin.create(context))
+        // use supplied ImageLoader instance
+        .usePlugin(CoilImagesPlugin.create(
+            context,
+            ImageLoader(context) {
+                availableMemoryPercentage(0.5)
+                bitmapPoolPercentage(0.5)
+                crossfade(true)
+            }
+        ))
+        // if you need more control
+        .usePlugin(CoilImagesPlugin.create(object : CoilImagesPlugin.CoilStore {
+            override fun load(drawable: AsyncDrawable): LoadRequest {
+                return LoadRequest(context, customImageLoader.defaults) {
+                    data(drawable.destination)
+                    crossfade(true)
+                    transformations(CircleCropTransformation())
+                }
+            }
+
+            override cancel(disposable: RequestDisposable) {
+                disposable.dispose()
+            }
+        }, customImageLoader))
+        .build()
+```
diff --git a/docs/docs/v4/inline-parser/README.md b/docs/docs/v4/inline-parser/README.md
new file mode 100644
index 00000000..e9638832
--- /dev/null
+++ b/docs/docs/v4/inline-parser/README.md
@@ -0,0 +1,75 @@
+# Inline Parser <Badge text="4.2.0" />
+
+**Experimental** commonmark-java inline parser that allows customizing 
+core features and/or extend with own. 
+
+Usage of _internal_ classes:
+```java
+import org.commonmark.internal.Bracket;
+import org.commonmark.internal.Delimiter;
+import org.commonmark.internal.util.Escaping;
+import org.commonmark.internal.util.Html5Entities;
+import org.commonmark.internal.util.LinkScanner;
+import org.commonmark.internal.util.Parsing;
+import org.commonmark.internal.inline.AsteriskDelimiterProcessor;
+import org.commonmark.internal.inline.UnderscoreDelimiterProcessor;
+```
+
+---
+
+```java
+// all default (like current commonmark-java InlineParserImpl) 
+final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder()
+        .build();
+```
+
+```java
+// disable images (current markdown images will be considered as links):
+final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder()
+        .excludeInlineProcessor(BangInlineProcessor.class)
+        .build();
+```
+
+```java
+// disable core delimiter processors for `*`|`_` and `**`|`__`
+final InlineParserFactory factory = MarkwonInlineParser.factoryBuilder()
+        .excludeDelimiterProcessor(AsteriskDelimiterProcessor.class)
+        .excludeDelimiterProcessor(UnderscoreDelimiterProcessor.class)
+        .build();
+```
+
+```java
+// disable _all_ markdown inlines except for links (open and close bracket handling `[` & `]`)
+final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilderNoDefaults()
+        // note that there is no `includeDefaults` method call
+        .referencesEnabled(true)
+        .addInlineProcessor(new OpenBracketInlineProcessor())
+        .addInlineProcessor(new CloseBracketInlineProcessor())
+        .build();
+```
+
+To use custom InlineParser:
+```java
+final Markwon markwon = Markwon.builder(this)
+        .usePlugin(new AbstractMarkwonPlugin() {
+            @Override
+            public void configureParser(@NonNull Parser.Builder builder) {
+                builder.inlineParserFactory(inlineParserFactory);
+            }
+        })
+        .build();
+```
+
+---
+
+The list of available inline processors:
+
+* `AutolinkInlineProcessor` (`<` =&gt; `<me@mydoma.in>`)
+* `BackslashInlineProcessor` (`\\`)
+* `BackticksInlineProcessor` (<code>&#96;</code> =&gt; <code>&#96;code&#96;</code>)
+* `BangInlineProcessor` (`!` =&gt; `![alt](#src)`)
+* `CloseBracketInlineProcessor` (`]` =&gt; `[link](#href)`, `![alt](#src)`)
+* `EntityInlineProcessor` (`&` =&gt; `&amp;`)
+* `HtmlInlineProcessor` (`<` =&gt; `<html></html>`)
+* `NewLineInlineProcessor` (`\n`)
+* `OpenBracketInlineProcessor` (`[` =&gt; `[link](#href)`)
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index e865da7b..b1d69d1b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -8,7 +8,7 @@ android.enableJetifier=true
 android.enableBuildCache=true
 android.buildCacheDir=build/pre-dex-cache
 
-VERSION_NAME=4.1.2
+VERSION_NAME=4.2.0
 
 GROUP=io.noties.markwon
 POM_DESCRIPTION=Markwon markdown for Android
diff --git a/markwon-core/src/main/java/io/noties/markwon/Markwon.java b/markwon-core/src/main/java/io/noties/markwon/Markwon.java
index ce8f956a..cd277e03 100644
--- a/markwon-core/src/main/java/io/noties/markwon/Markwon.java
+++ b/markwon-core/src/main/java/io/noties/markwon/Markwon.java
@@ -134,6 +134,9 @@ public abstract class Markwon {
     @NonNull
     public abstract List<? extends MarkwonPlugin> getPlugins();
 
+    @NonNull
+    public abstract MarkwonConfiguration configuration();
+
     /**
      * Interface to set text on a TextView. Primary goal is to give a way to use PrecomputedText
      * functionality
@@ -141,21 +144,21 @@ public abstract class Markwon {
      * @see PrecomputedTextSetterCompat
      * @since 4.1.0
      */
-public interface TextSetter {
-    /**
-     * @param textView   TextView
-     * @param markdown   prepared markdown
-     * @param bufferType BufferType specified when building {@link Markwon} instance
-     *                   via {@link Builder#bufferType(TextView.BufferType)}
-     * @param onComplete action to run when set-text is finished (required to call in order
-     *                   to execute {@link MarkwonPlugin#afterSetText(TextView)})
-     */
-    void setText(
-            @NonNull TextView textView,
-            @NonNull Spanned markdown,
-            @NonNull TextView.BufferType bufferType,
-            @NonNull Runnable onComplete);
-}
+    public interface TextSetter {
+        /**
+         * @param textView   TextView
+         * @param markdown   prepared markdown
+         * @param bufferType BufferType specified when building {@link Markwon} instance
+         *                   via {@link Builder#bufferType(TextView.BufferType)}
+         * @param onComplete action to run when set-text is finished (required to call in order
+         *                   to execute {@link MarkwonPlugin#afterSetText(TextView)})
+         */
+        void setText(
+                @NonNull TextView textView,
+                @NonNull Spanned markdown,
+                @NonNull TextView.BufferType bufferType,
+                @NonNull Runnable onComplete);
+    }
 
     /**
      * Builder for {@link Markwon}.
diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java
index 83dc2cfe..2ae70e18 100644
--- a/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java
+++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java
@@ -113,6 +113,7 @@ class MarkwonBuilderImpl implements Markwon.Builder {
                 textSetter,
                 parserBuilder.build(),
                 visitorFactory,
+                configuration,
                 Collections.unmodifiableList(plugins)
         );
     }
diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java
index 3f0ee18c..5aced55c 100644
--- a/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java
+++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java
@@ -21,6 +21,7 @@ class MarkwonImpl extends Markwon {
     private final TextView.BufferType bufferType;
     private final Parser parser;
     private final MarkwonVisitorFactory visitorFactory; // @since 4.1.1
+    private final MarkwonConfiguration configuration;
     private final List<MarkwonPlugin> plugins;
 
     // @since 4.1.0
@@ -32,11 +33,13 @@ class MarkwonImpl extends Markwon {
             @Nullable TextSetter textSetter,
             @NonNull Parser parser,
             @NonNull MarkwonVisitorFactory visitorFactory,
+            @NonNull MarkwonConfiguration configuration,
             @NonNull List<MarkwonPlugin> plugins) {
         this.bufferType = bufferType;
         this.textSetter = textSetter;
         this.parser = parser;
         this.visitorFactory = visitorFactory;
+        this.configuration = configuration;
         this.plugins = plugins;
     }
 
@@ -154,4 +157,10 @@ class MarkwonImpl extends Markwon {
     public List<? extends MarkwonPlugin> getPlugins() {
         return Collections.unmodifiableList(plugins);
     }
+
+    @NonNull
+    @Override
+    public MarkwonConfiguration configuration() {
+        return configuration;
+    }
 }
diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java
index c6361a00..659a6622 100644
--- a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java
+++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java
@@ -18,6 +18,7 @@ import org.commonmark.node.HtmlInline;
 import org.commonmark.node.Image;
 import org.commonmark.node.IndentedCodeBlock;
 import org.commonmark.node.Link;
+import org.commonmark.node.LinkReferenceDefinition;
 import org.commonmark.node.ListItem;
 import org.commonmark.node.Node;
 import org.commonmark.node.OrderedList;
@@ -155,6 +156,11 @@ class MarkwonVisitorImpl implements MarkwonVisitor {
         visit((Node) text);
     }
 
+    @Override
+    public void visit(LinkReferenceDefinition linkReferenceDefinition) {
+        visit((Node) linkReferenceDefinition);
+    }
+
     @Override
     public void visit(CustomBlock customBlock) {
         visit((Node) customBlock);
diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/HeadingSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/HeadingSpan.java
index cb723a30..28cf3f35 100644
--- a/markwon-core/src/main/java/io/noties/markwon/core/spans/HeadingSpan.java
+++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/HeadingSpan.java
@@ -77,4 +77,11 @@ public class HeadingSpan extends MetricAffectingSpan implements LeadingMarginSpa
             }
         }
     }
+
+    /**
+     * @since 4.2.0
+     */
+    public int getLevel() {
+        return level;
+    }
 }
diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java
index f8483423..961afe26 100644
--- a/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java
+++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/LinkSpan.java
@@ -31,7 +31,15 @@ public class LinkSpan extends URLSpan {
     }
 
     @Override
-    public void updateDrawState(TextPaint ds) {
+    public void updateDrawState(@NonNull TextPaint ds) {
         theme.applyLinkStyle(ds);
     }
+
+    /**
+     * @since 4.2.0
+     */
+    @NonNull
+    public String getLink() {
+        return link;
+    }
 }
diff --git a/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java b/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java
index 2eb4f3f8..0d9024d4 100644
--- a/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java
+++ b/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java
@@ -49,6 +49,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 Collections.singletonList(plugin));
 
         impl.parse("whatever");
@@ -72,6 +73,7 @@ public class MarkwonImplTest {
                 null,
                 parser,
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 Arrays.asList(first, second));
 
         impl.parse("zero");
@@ -99,6 +101,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 visitorFactory,
+                mock(MarkwonConfiguration.class),
                 Collections.singletonList(plugin));
 
         when(visitorFactory.create()).thenReturn(visitor);
@@ -145,6 +148,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 visitorFactory,
+                mock(MarkwonConfiguration.class),
                 Collections.<MarkwonPlugin>emptyList());
 
         impl.render(mock(Node.class));
@@ -180,6 +184,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 visitorFactory,
+                mock(MarkwonConfiguration.class),
                 Collections.singletonList(plugin));
 
         final AtomicBoolean flag = new AtomicBoolean(false);
@@ -218,6 +223,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class, RETURNS_MOCKS),
+                mock(MarkwonConfiguration.class),
                 Collections.singletonList(plugin));
 
         final TextView textView = mock(TextView.class);
@@ -265,6 +271,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 plugins);
 
         assertTrue("First", impl.hasPlugin(First.class));
@@ -287,6 +294,7 @@ public class MarkwonImplTest {
                 textSetter,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 Collections.singletonList(plugin));
 
         final TextView textView = mock(TextView.class);
@@ -330,6 +338,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 plugins);
 
         // should be returned
@@ -360,6 +369,7 @@ public class MarkwonImplTest {
                 null,
                 mock(Parser.class),
                 mock(MarkwonVisitorFactory.class),
+                mock(MarkwonConfiguration.class),
                 plugins);
 
         final List<? extends MarkwonPlugin> list = impl.getPlugins();
diff --git a/markwon-core/src/test/java/io/noties/markwon/core/suite/OrderedListTest.java b/markwon-core/src/test/java/io/noties/markwon/core/suite/OrderedListTest.java
index afd98aeb..e89b819f 100644
--- a/markwon-core/src/test/java/io/noties/markwon/core/suite/OrderedListTest.java
+++ b/markwon-core/src/test/java/io/noties/markwon/core/suite/OrderedListTest.java
@@ -50,6 +50,23 @@ public class OrderedListTest extends BaseSuiteTest {
   @Test
   public void two_spaces() {
     // just a regular flat-list (no sub-lists)
+    // UPD: cannot have more than 3 spaces (0.29), now it is:
+    // 1. First
+    // 2. Second 3. Third
+
+//    final Document document = document(
+//      span(ORDERED_LIST,
+//        args("start", 1),
+//        text("First")),
+//      text("\n"),
+//      span(ORDERED_LIST,
+//        args("start", 2),
+//        text("Second")),
+//      text("\n"),
+//      span(ORDERED_LIST,
+//        args("start", 3),
+//        text("Third"))
+//    );
 
     final Document document = document(
       span(ORDERED_LIST,
@@ -58,11 +75,7 @@ public class OrderedListTest extends BaseSuiteTest {
       text("\n"),
       span(ORDERED_LIST,
         args("start", 2),
-        text("Second")),
-      text("\n"),
-      span(ORDERED_LIST,
-        args("start", 3),
-        text("Third"))
+        text("Second 3. Third"))
     );
 
     matchInput("ol-2-spaces.md", document);
diff --git a/markwon-editor/README.md b/markwon-editor/README.md
new file mode 100644
index 00000000..388306e3
--- /dev/null
+++ b/markwon-editor/README.md
@@ -0,0 +1,16 @@
+# Editor
+
+Markdown editor for Android based on `Markwon`.
+
+Main principle: _difference_ between input text and rendered markdown is considered to be
+_punctuation_.
+
+
+[https://noties.io/Markwon/docs/v4/editor/](https://noties.io/Markwon/docs/v4/editor/)
+
+
+## Limitations
+
+Tables and LaTeX nodes won't be rendered correctly. They will be treated as _punctuation_
+as whole. This comes from their implementation - they are _mocked_ and do not present
+in final result as text and thus cannot be _diffed_. 
\ No newline at end of file
diff --git a/markwon-editor/build.gradle b/markwon-editor/build.gradle
new file mode 100644
index 00000000..cc7ad811
--- /dev/null
+++ b/markwon-editor/build.gradle
@@ -0,0 +1,32 @@
+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')
+
+    deps['test'].with {
+
+        testImplementation project(':markwon-test-span')
+
+        testImplementation it['junit']
+        testImplementation it['robolectric']
+        testImplementation it['mockito']
+
+        testImplementation it['commons-io']
+    }
+}
+
+registerArtifact(this)
\ No newline at end of file
diff --git a/markwon-editor/gradle.properties b/markwon-editor/gradle.properties
new file mode 100644
index 00000000..01e02510
--- /dev/null
+++ b/markwon-editor/gradle.properties
@@ -0,0 +1,4 @@
+POM_NAME=Editor
+POM_ARTIFACT_ID=editor
+POM_DESCRIPTION=Markdown editor based on Markwon
+POM_PACKAGING=aar
diff --git a/markwon-editor/src/main/AndroidManifest.xml b/markwon-editor/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..59243778
--- /dev/null
+++ b/markwon-editor/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="io.noties.markwon.editor" />
\ No newline at end of file
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java
new file mode 100644
index 00000000..25a556c0
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/AbstractEditHandler.java
@@ -0,0 +1,18 @@
+package io.noties.markwon.editor;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.Markwon;
+
+/**
+ * @see EditHandler
+ * @see io.noties.markwon.editor.handler.EmphasisEditHandler
+ * @see io.noties.markwon.editor.handler.StrongEmphasisEditHandler
+ * @since 4.2.0
+ */
+public abstract class AbstractEditHandler<T> implements EditHandler<T> {
+    @Override
+    public void init(@NonNull Markwon markwon) {
+
+    }
+}
diff --git a/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java b/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java
new file mode 100644
index 00000000..999cb2c0
--- /dev/null
+++ b/markwon-editor/src/main/java/io/noties/markwon/editor/EditHandler.java
@@ -0,0 +1,47 @@
+package io.noties.markwon.editor;
+
+import android.text.Editable;
+
+import androidx.annotation.NonNull;
+
+import io.noties.markwon.Markwon;
+import io.noties.markwon.editor.handler.EmphasisEditHandler;
+import io.noties.markwon.editor.handler.StrongEmphasisEditHandler;
+
+/**
+ * @see EmphasisEditHandler
+ * @see StrongEmphasisEditHandler
+ * @since 4.2.0
+ */
+public interface EditHandler<T> {
+
+    void init(@NonNull Markwon markwon);
+
+    void configurePersistedSpans(@NonNull PersistedSpans.Builder builder);
+
+    // span is present only in off-screen rendered markdown, it must be processed and
+    //  a NEW one must be added to editable (via edit-persist-spans)
+    //
+    // NB, editable.setSpan must obtain span from `spans` and must be configured beforehand
+    // multiple spans are OK as long as they are configured
+
+    /**
+     * @param persistedSpans
+     * @param editable
+     * @param input
+     * @param span
+     * @param spanStart
+     * @param spanTextLength
+     * @see MarkwonEditorUtils
+     */
+    void handleMarkdownSpan(
+            @NonNull PersistedSpans persistedSpans,
+            @NonNull Editable editable,
+            @NonNull String input,
+            @NonNull T span,
+            int spanStart,
+            int spanTextLength);
+
+    @NonNull
+    Class<T> 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.
+     * <p>
+     * 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<Class<?>, EditHandler> editHandlers = new HashMap<>(0);
+
+        private Class<?> punctuationSpanType;
+
+        Builder(@NonNull Markwon markwon) {
+            this.markwon = markwon;
+        }
+
+        @NonNull
+        public <T> Builder useEditHandler(@NonNull EditHandler<T> 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 <T> Builder punctuationSpan(@NonNull Class<T> type, @NonNull PersistedSpans.SpanFactory<T> 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<PunctuationSpan>() {
+                    @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<Class<?>, EditHandler> spanHandlers;
+
+        SpansHandlerImpl(@NonNull Map<Class<?>, 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<Diff> 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<Span> applied = new ArrayList<>(3);
+        final List<Object> 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<Class<?>, List<Object>> extractSpans(@NonNull Spanned spanned, @NonNull Collection<Class<?>> types) {
+
+        final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
+        final Map<Class<?>, List<Object>> map = new HashMap<>(3);
+
+        Class<?> type;
+
+        for (Object span : spans) {
+            type = span.getClass();
+            if (types.contains(type)) {
+                List<Object> 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<T> {
+        @NonNull
+        T create();
+    }
+
+    public interface Builder {
+        @SuppressWarnings("UnusedReturnValue")
+        @NonNull
+        <T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> spanFactory);
+    }
+
+    @NonNull
+    public abstract <T> T get(@NonNull Class<T> type);
+
+    abstract void removeUnused();
+
+
+    @NonNull
+    static Provider provider() {
+        return new Provider();
+    }
+
+    static class Provider implements Builder {
+
+        private final Map<Class<?>, SpanFactory> map = new HashMap<>(3);
+
+        @NonNull
+        @Override
+        public <T> Builder persistSpan(@NonNull Class<T> type, @NonNull SpanFactory<T> 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<Class<?>, SpanFactory> spans;
+        private final Map<Class<?>, List<Object>> map;
+
+        Impl(@NonNull Spannable spannable, @NonNull Map<Class<?>, SpanFactory> spans) {
+            this.spannable = spannable;
+            this.spans = spans;
+            this.map = extractSpans(spannable, spans.keySet());
+        }
+
+        @NonNull
+        @Override
+        public <T> T get(@NonNull Class<T> type) {
+
+            final Object span;
+
+            final List<Object> 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<Object> 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<String> lineArray;
+
+        protected LinesToCharsResult(String chars1, String chars2,
+                                     List<String> 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> 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> 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> 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<Diff> diffs;
+        if (text1.equals(text2)) {
+            diffs = new LinkedList<Diff>();
+            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> diff_compute(String text1, String text2,
+                                          boolean checklines) {
+        LinkedList<Diff> diffs = new LinkedList<Diff>();
+
+        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<Diff> diffs_a = diff_main(text1_a, text2_a,
+                    checklines);
+            LinkedList<Diff> 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> 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<String> linearray = a.lineArray;
+
+        LinkedList<Diff> 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<Diff> 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> 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<Diff> diffs = new LinkedList<Diff>();
+        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> 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<Diff> diffs = diff_main(text1a, text2a, false);
+        LinkedList<Diff> 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<String> lineArray = new ArrayList<String>();
+        Map<String, Integer> lineHash = new HashMap<String, Integer>();
+        // 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<String> lineArray,
+                                          Map<String, Integer> 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<Diff> diffs,
+                                     List<String> 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<Diff> diffs) {
+        if (diffs.isEmpty()) {
+            return;
+        }
+        boolean changes = false;
+        Deque<Diff> equalities = new ArrayDeque<Diff>();  // Double-ended queue of qualities.
+        String lastEquality = null; // Always equal to equalities.peek().text
+        ListIterator<Diff> 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: <del>abcxxx</del><ins>xxxdef</ins>
+        //   -> <del>abc</del>xxx<ins>def</ins>
+        // e.g: <del>xxxabc</del><ins>defxxx</ins>
+        //   -> <ins>def</ins>xxx<del>abc</del>
+        // 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 c<ins>at c</ins>ame. -> The <ins>cat </ins>came.
+     *
+     * @param diffs LinkedList of Diff objects.
+     */
+    private static void diff_cleanupSemanticLossless(LinkedList<Diff> diffs) {
+        String equality1, edit, equality2;
+        String commonString;
+        int commonOffset;
+        int score, bestScore;
+        String bestEquality1, bestEdit, bestEquality2;
+        // Create a new iterator at the start.
+        ListIterator<Diff> 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<Diff> diffs) {
+//        if (diffs.isEmpty()) {
+//            return;
+//        }
+//        boolean changes = false;
+//        Deque<Diff> equalities = new ArrayDeque<Diff>();  // Double-ended queue of equalities.
+//        String lastEquality = null; // Always equal to equalities.peek().text
+//        ListIterator<Diff> 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:
+//                 * <ins>A</ins><del>B</del>XY<ins>C</ins><del>D</del>
+//                 * <ins>A</ins>X<ins>C</ins><del>D</del>
+//                 * <ins>A</ins><del>B</del>X<ins>C</ins>
+//                 * <ins>A</del>X<ins>C</ins><del>D</del>
+//                 * <ins>A</ins><del>B</del>X<del>C</del>
+//                 */
+//                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<Diff> diffs) {
+        diffs.add(new Diff(Operation.EQUAL, ""));  // Add a dummy entry at the end.
+        ListIterator<Diff> 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: A<ins>BA</ins>C -> <ins>AB</ins>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<Diff> 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<Diff> diffs) {
+//        StringBuilder html = new StringBuilder();
+//        for (Diff aDiff : diffs) {
+//            String text = aDiff.text.replace("&", "&amp;").replace("<", "&lt;")
+//                    .replace(">", "&gt;").replace("\n", "&para;<br>");
+//            switch (aDiff.operation) {
+//                case INSERT:
+//                    html.append("<ins style=\"background:#e6ffe6;\">").append(text)
+//                            .append("</ins>");
+//                    break;
+//                case DELETE:
+//                    html.append("<del style=\"background:#ffe6e6;\">").append(text)
+//                            .append("</del>");
+//                    break;
+//                case EQUAL:
+//                    html.append("<span>").append(text).append("</span>");
+//                    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<Diff> 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<Diff> 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<Diff> 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<Diff> 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> diff_fromDelta(String text1, String delta)
+//            throws IllegalArgumentException {
+//        LinkedList<Diff> diffs = new LinkedList<Diff>();
+//        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<Character, Integer> 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<Character, Integer> match_alphabet(String pattern) {
+//        Map<Character, Integer> s = new HashMap<Character, Integer>();
+//        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> 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<Diff> 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> patch_make(LinkedList<Diff> 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<Diff> diffs).
+//     */
+//    @Deprecated
+//    public LinkedList<Patch> patch_make(String text1, String text2,
+//                                        LinkedList<Diff> 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> patch_make(String text1, LinkedList<Diff> diffs) {
+//        if (text1 == null || diffs == null) {
+//            throw new IllegalArgumentException("Null inputs. (patch_make)");
+//        }
+//
+//        LinkedList<Patch> patches = new LinkedList<Patch>();
+//        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> patch_deepCopy(LinkedList<Patch> patches) {
+//        LinkedList<Patch> patchesCopy = new LinkedList<Patch>();
+//        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<Patch> 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<Diff> 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<Patch> 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<Diff> 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<Patch> patches) {
+//        short patch_size = Match_MaxBits;
+//        String precontext, postcontext;
+//        Patch patch;
+//        int start1, start2;
+//        boolean empty;
+//        Operation diff_type;
+//        String diff_text;
+//        ListIterator<Patch> 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<Patch> 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> patch_fromText(String textline)
+//            throws IllegalArgumentException {
+//        List<Patch> patches = new LinkedList<Patch>();
+//        if (textline.length() == 0) {
+//            return patches;
+//        }
+//        List<String> textList = Arrays.asList(textline.split("\n"));
+//        LinkedList<String> text = new LinkedList<String>(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<Diff> 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<Diff>();
+//    }
+//
+//    /**
+//     * 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.
+//     * <p>
+//     * 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<EmphasisSpan> {
+
+    @Override
+    public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+        builder.persistSpan(EmphasisSpan.class, new PersistedSpans.SpanFactory<EmphasisSpan>() {
+            @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<EmphasisSpan> 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<StrongEmphasisSpan> {
+
+    @NonNull
+    public static StrongEmphasisEditHandler create() {
+        return new StrongEmphasisEditHandler();
+    }
+
+    @Override
+    public void configurePersistedSpans(@NonNull PersistedSpans.Builder builder) {
+        builder.persistSpan(StrongEmphasisSpan.class, new PersistedSpans.SpanFactory<StrongEmphasisSpan>() {
+            @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<StrongEmphasisSpan> 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<Object>() {
+            @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<PreRenderResultListener> 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<Object>() {
+            @Override
+            public Object answer(InvocationOnMock invocation) {
+                ((Runnable) invocation.getArgument(0)).run();
+                return null;
+            }
+        });
+
+        final ArgumentCaptor<Runnable> 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<Class<?>, List<Object>> 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<Class<?>, 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<Class<?>, SpanFactory> map = new HashMap<Class<?>, 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<Class<?>, SpanFactory> map = new HashMap<Class<?>, 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 @@
+<manifest package="io.noties.markwon.image.coil" />
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<AsyncDrawable, RequestDisposable> 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<String> 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 @@
+<manifest package="io.noties.markwon.inlineparser" />
\ 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 <me@mydoma.in>}
+ *
+ * @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 ![alt](#href)}
+ *
+ * @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 &amp;}
+ *
+ * @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 = "<![A-Z]+\\s+[^>]*>";
+    private static final String CDATA = "<!\\[CDATA\\[[\\s\\S]*?\\]\\]>";
+    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 <em>empty</em> builder, so if no {@link FactoryBuilderNoDefaults#includeDefaults()}
+     * is called, it means effectively <strong>no inline parsing</strong> (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<Character, List<InlineProcessor>> inlineProcessors;
+    private final Map<Character, DelimiterProcessor> 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 (<code>[</code> or <code>![)</code>).
+     */
+    private Bracket lastBracket;
+
+    // might we construct these in factory?
+    public MarkwonInlineParser(
+            @NonNull InlineParserContext inlineParserContext,
+            boolean referencesEnabled,
+            @NonNull List<InlineProcessor> inlineProcessors,
+            @NonNull List<DelimiterProcessor> 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<Character, List<InlineProcessor>> calculateInlines(@NonNull List<InlineProcessor> inlines) {
+        final Map<Character, List<InlineProcessor>> map = new HashMap<>(inlines.size());
+        List<InlineProcessor> 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<Character> inlineCharacters, Set<Character> 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<Character, DelimiterProcessor> calculateDelimiterProcessors(List<DelimiterProcessor> delimiterProcessors) {
+        Map<Character, DelimiterProcessor> map = new HashMap<>();
+        addDelimiterProcessors(delimiterProcessors, map);
+        return map;
+    }
+
+    private static void addDelimiterProcessors(Iterable<DelimiterProcessor> delimiterProcessors, Map<Character, DelimiterProcessor> 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<Character, DelimiterProcessor> 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<InlineProcessor> 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<Character, Delimiter> 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<InlineProcessor> inlineProcessors = new ArrayList<>(3);
+        private final List<DelimiterProcessor> 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<InlineProcessor> inlineProcessors;
+        private final List<DelimiterProcessor> delimiterProcessors;
+
+        InlineParserFactoryImpl(
+                boolean referencesEnabled,
+                @NonNull List<InlineProcessor> inlineProcessors,
+                @NonNull List<DelimiterProcessor> delimiterProcessors) {
+            this.referencesEnabled = referencesEnabled;
+            this.inlineProcessors = inlineProcessors;
+            this.delimiterProcessors = delimiterProcessors;
+        }
+
+        @Override
+        public InlineParser create(InlineParserContext inlineParserContext) {
+            final List<DelimiterProcessor> delimiterProcessors;
+            final List<DelimiterProcessor> 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<DelimiterProcessor> 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<DelimiterProcessor> 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 @@
         <activity android:name=".customextension2.CustomExtensionActivity2" />
         <activity android:name=".precomputed.PrecomputedActivity" />
 
+        <activity
+            android:name=".editor.EditorActivity"
+            android:windowSoftInputMode="adjustResize" />
+
+        <activity android:name=".inlineparser.InlineParserActivity" />
+
     </application>
 
 </manifest>
\ 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<BlockQuoteSpan> {
+
+    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<BlockQuoteSpan> 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<CodeSpan> {
+
+    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<CodeSpan> 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<StrongEmphasisSpan>() {
+                    @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<StrongEmphasisSpan> 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 `&amp;` 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<Integer> 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<LinkSpan> {
+
+    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<LinkSpan> 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<StrikethroughSpan> {
+
+    @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<StrikethroughSpan> 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 (&amp;)
+        //  * 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_** <u>html-u</u>, [link](#) ![alt](#image) `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<Class<? extends Block>> enabledBlocks = new HashSet<Class<? extends Block>>() {{
+            // 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:clipToPadding="false"
+    android:orientation="vertical"
+    android:padding="8dip">
+
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="0px"
+        android:layout_weight="1">
+
+        <EditText
+            android:id="@+id/edit_text"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:autofillHints="none"
+            android:hint="Markdown..."
+            android:inputType="text|textLongMessage|textMultiLine"
+            android:maxLines="100" />
+
+    </FrameLayout>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal">
+
+        <Button
+            android:id="@+id/bold"
+            android:layout_width="0px"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="B"
+            android:typeface="monospace" />
+
+        <Button
+            android:id="@+id/italic"
+            android:layout_width="0px"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="I"
+            android:typeface="monospace" />
+
+        <Button
+            android:id="@+id/strike"
+            android:layout_width="0px"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="S"
+            android:typeface="monospace" />
+
+        <Button
+            android:id="@+id/quote"
+            android:layout_width="0px"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text=">"
+            android:typeface="monospace" />
+
+        <Button
+            android:id="@+id/code"
+            android:layout_width="0px"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="`"
+            android:typeface="monospace" />
+
+    </LinearLayout>
+
+</LinearLayout>
\ 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 @@
 
     <string name="sample_precomputed_text"># \# PrecomputedText\n\nUsage of TextSetter and PrecomputedTextCompat</string>
 
+    <string name="sample_editor"># \# Editor\n\n`MarkwonEditor` sample usage to highlight user input in EditText</string>
+
+    <string name="sample_inline_parser"># \# Inline Parser\n\nUsage of custom inline parser</string>
+
 </resources>
\ 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',