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.®isterArtifact 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` (`<` => `<me@mydoma.in>`) +* `BackslashInlineProcessor` (`\\`) +* `BackticksInlineProcessor` (<code>`</code> => <code>`code`</code>) +* `BangInlineProcessor` (`!` => ``) +* `CloseBracketInlineProcessor` (`]` => `[link](#href)`, ``) +* `EntityInlineProcessor` (`&` => `&`) +* `HtmlInlineProcessor` (`<` => `<html></html>`) +* `NewLineInlineProcessor` (`\n`) +* `OpenBracketInlineProcessor` (`[` => `[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("&", "&").replace("<", "<") +// .replace(">", ">").replace("\n", "¶<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 } + * + * @since 4.2.0 + */ +public class BangInlineProcessor extends InlineProcessor { + @Override + public char specialCharacter() { + return '!'; + } + + @Override + protected Node parse() { + int startIndex = index; + index++; + if (peek() == '[') { + index++; + + Text node = text("!["); + + // Add entry to stack for this opener + addBracket(Bracket.image(node, startIndex + 1, lastBracket(), lastDelimiter())); + + return node; + } else { + return text("!"); + } + } +} diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java new file mode 100644 index 00000000..b9d2f867 --- /dev/null +++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/CloseBracketInlineProcessor.java @@ -0,0 +1,140 @@ +package io.noties.markwon.inlineparser; + +import org.commonmark.internal.Bracket; +import org.commonmark.internal.util.Escaping; +import org.commonmark.node.Image; +import org.commonmark.node.Link; +import org.commonmark.node.LinkReferenceDefinition; +import org.commonmark.node.Node; + +import java.util.regex.Pattern; + +import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes; + +/** + * Parses markdown link or image, relies on {@link OpenBracketInlineProcessor} + * to handle start of these elements + * + * @since 4.2.0 + */ +public class CloseBracketInlineProcessor extends InlineProcessor { + + private static final Pattern WHITESPACE = MarkwonInlineParser.WHITESPACE; + + @Override + public char specialCharacter() { + return ']'; + } + + @Override + protected Node parse() { + index++; + int startIndex = index; + + // Get previous `[` or `![` + Bracket opener = lastBracket(); + if (opener == null) { + // No matching opener, just return a literal. + return text("]"); + } + + if (!opener.allowed) { + // Matching opener but it's not allowed, just return a literal. + removeLastBracket(); + return text("]"); + } + + // Check to see if we have a link/image + + String dest = null; + String title = null; + boolean isLinkOrImage = false; + + // Maybe a inline link like `[foo](/uri "title")` + if (peek() == '(') { + index++; + spnl(); + if ((dest = parseLinkDestination()) != null) { + spnl(); + // title needs a whitespace before + if (WHITESPACE.matcher(input.substring(index - 1, index)).matches()) { + title = parseLinkTitle(); + spnl(); + } + if (peek() == ')') { + index++; + isLinkOrImage = true; + } else { + index = startIndex; + } + } + } + + // Maybe a reference link like `[foo][bar]`, `[foo][]` or `[foo]` + if (!isLinkOrImage) { + + // See if there's a link label like `[bar]` or `[]` + int beforeLabel = index; + parseLinkLabel(); + int labelLength = index - beforeLabel; + String ref = null; + if (labelLength > 2) { + ref = input.substring(beforeLabel, beforeLabel + labelLength); + } else if (!opener.bracketAfter) { + // If the second label is empty `[foo][]` or missing `[foo]`, then the first label is the reference. + // But it can only be a reference when there's no (unescaped) bracket in it. + // If there is, we don't even need to try to look up the reference. This is an optimization. + ref = input.substring(opener.index, startIndex); + } + + if (ref != null) { + String label = Escaping.normalizeReference(ref); + LinkReferenceDefinition definition = context.getLinkReferenceDefinition(label); + if (definition != null) { + dest = definition.getDestination(); + title = definition.getTitle(); + isLinkOrImage = true; + } + } + } + + if (isLinkOrImage) { + // If we got here, open is a potential opener + Node linkOrImage = opener.image ? new Image(dest, title) : new Link(dest, title); + + Node node = opener.node.getNext(); + while (node != null) { + Node next = node.getNext(); + linkOrImage.appendChild(node); + node = next; + } + + // Process delimiters such as emphasis inside link/image + processDelimiters(opener.previousDelimiter); + mergeChildTextNodes(linkOrImage); + // We don't need the corresponding text node anymore, we turned it into a link/image node + opener.node.unlink(); + removeLastBracket(); + + // Links within links are not allowed. We found this link, so there can be no other link around it. + if (!opener.image) { + Bracket bracket = lastBracket(); + while (bracket != null) { + if (!bracket.image) { + // Disallow link opener. It will still get matched, but will not result in a link. + bracket.allowed = false; + } + bracket = bracket.previous; + } + } + + return linkOrImage; + + } else { // no link or image + index = startIndex; + removeLastBracket(); + + return text("]"); + } + } +} diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java new file mode 100644 index 00000000..353f9902 --- /dev/null +++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/EntityInlineProcessor.java @@ -0,0 +1,32 @@ +package io.noties.markwon.inlineparser; + +import org.commonmark.internal.util.Escaping; +import org.commonmark.internal.util.Html5Entities; +import org.commonmark.node.Node; + +import java.util.regex.Pattern; + +/** + * Parses HTML entities {@code &} + * + * @since 4.2.0 + */ +public class EntityInlineProcessor extends InlineProcessor { + + private static final Pattern ENTITY_HERE = Pattern.compile('^' + Escaping.ENTITY, Pattern.CASE_INSENSITIVE); + + @Override + public char specialCharacter() { + return '&'; + } + + @Override + protected Node parse() { + String m; + if ((m = match(ENTITY_HERE)) != null) { + return text(Html5Entities.entityToString(m)); + } else { + return null; + } + } +} diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java new file mode 100644 index 00000000..d3bd579d --- /dev/null +++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/HtmlInlineProcessor.java @@ -0,0 +1,40 @@ +package io.noties.markwon.inlineparser; + +import org.commonmark.internal.util.Parsing; +import org.commonmark.node.HtmlInline; +import org.commonmark.node.Node; + +import java.util.regex.Pattern; + +/** + * Parses inline HTML tags + * + * @since 4.2.0 + */ +public class HtmlInlineProcessor extends InlineProcessor { + + private static final String HTMLCOMMENT = "<!---->|<!--(?:-?[^>-])(?:-?[^-])*-->"; + private static final String PROCESSINGINSTRUCTION = "[<][?].*?[?][>]"; + private static final String DECLARATION = "<![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 `&` 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 (&) + // * 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](#)  `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',