diff --git a/CHANGELOG.md b/CHANGELOG.md index 886bd169..38b9e884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +# 4.4.0 +* `TextViewSpan` to obtain `TextView` in which markdown is displayed (applied by `CorePlugin`) +* `TextLayoutSpan` to obtain `Layout` in which markdown is displayed (applied by `TablePlugin`, more specifically `TableRowSpan` to propagate layout in which cell content is displayed) +* `HtmlEmptyTagReplacement` now is configurable by `HtmlPlugin`, `iframe` handling ([#235]) +* `AsyncDrawable` now uses `TextView` width without padding instead of width of canvas +* Support for images inside table cells (`ext-tables` module) +* Expose `enabledBlockTypes` in `CorePlugin` +* Update `jlatexmath-android` dependency ([#225]) +* Update `image-coil` module (Coil version `0.10.1`) ([#244])
Thanks to [@tylerbwong] +* Rename `UrlProcessor` to `ImageDestinationProcessor` (`io.noties.markwon.urlprocessor` -> `io.noties.markwon.image.destination`) and limit its usage to process **only** destination URL of images (was used to also process links before) +* `fallbackToRawInputWhenEmpty` `Markwon.Builder` configuration to fallback to raw input if rendered markdown is empty ([#242]) + +[#235]: https://github.com/noties/Markwon/issues/235 +[#225]: https://github.com/noties/Markwon/issues/225 +[#244]: https://github.com/noties/Markwon/pull/244 +[#242]: https://github.com/noties/Markwon/issues/242 +[@tylerbwong]: https://github.com/tylerbwong + + # 4.3.1 * Fix DexGuard optimization issue ([#216])
Thanks [@francescocervone] * module `images`: `GifSupport` and `SvgSupport` use `Class.forName` instead access to full qualified class name diff --git a/app/src/main/java/io/noties/markwon/app/UrlProcessorInitialReadme.java b/app/src/main/java/io/noties/markwon/app/ImageDestinationProcessorInitialReadme.java similarity index 59% rename from app/src/main/java/io/noties/markwon/app/UrlProcessorInitialReadme.java rename to app/src/main/java/io/noties/markwon/app/ImageDestinationProcessorInitialReadme.java index 571bb395..26653af9 100644 --- a/app/src/main/java/io/noties/markwon/app/UrlProcessorInitialReadme.java +++ b/app/src/main/java/io/noties/markwon/app/ImageDestinationProcessorInitialReadme.java @@ -5,15 +5,15 @@ import androidx.annotation.NonNull; -import io.noties.markwon.urlprocessor.UrlProcessor; -import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; +import io.noties.markwon.image.destination.ImageDestinationProcessor; +import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; -class UrlProcessorInitialReadme implements UrlProcessor { +class ImageDestinationProcessorInitialReadme extends ImageDestinationProcessor { private static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/"; - private final UrlProcessorRelativeToAbsolute processor - = new UrlProcessorRelativeToAbsolute(GITHUB_BASE); + private final ImageDestinationProcessorRelativeToAbsolute processor + = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE); @NonNull @Override diff --git a/app/src/main/java/io/noties/markwon/app/MarkdownRenderer.java b/app/src/main/java/io/noties/markwon/app/MarkdownRenderer.java index cc339083..d820733e 100644 --- a/app/src/main/java/io/noties/markwon/app/MarkdownRenderer.java +++ b/app/src/main/java/io/noties/markwon/app/MarkdownRenderer.java @@ -24,6 +24,8 @@ import io.noties.markwon.ext.tasklist.TaskListPlugin; import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.destination.ImageDestinationProcessor; +import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; import io.noties.markwon.image.file.FileSchemeHandler; import io.noties.markwon.image.gif.GifMediaDecoder; import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; @@ -31,8 +33,6 @@ import io.noties.markwon.syntax.Prism4jThemeDarkula; import io.noties.markwon.syntax.Prism4jThemeDefault; import io.noties.markwon.syntax.SyntaxHighlightPlugin; -import io.noties.markwon.urlprocessor.UrlProcessor; -import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; import io.noties.prism4j.Prism4j; @ActivityScope @@ -86,11 +86,11 @@ public void run() { } private void execute() { - final UrlProcessor urlProcessor; + final ImageDestinationProcessor imageDestinationProcessor; if (uri == null) { - urlProcessor = new UrlProcessorInitialReadme(); + imageDestinationProcessor = new ImageDestinationProcessorInitialReadme(); } else { - urlProcessor = new UrlProcessorRelativeToAbsolute(uri.toString()); + imageDestinationProcessor = new ImageDestinationProcessorRelativeToAbsolute(uri.toString()); } final Prism4jTheme prism4jTheme = isLightTheme @@ -119,7 +119,7 @@ public void configureImages(@NonNull ImagesPlugin plugin) { .usePlugin(new AbstractMarkwonPlugin() { @Override public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { - builder.urlProcessor(urlProcessor); + builder.imageDestinationProcessor(imageDestinationProcessor); } }) .build(); diff --git a/build.gradle b/build.gradle index 51aa9d6c..b92f8dd9 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext { 'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion", 'android-svg' : 'com.caverock:androidsvg:1.4', 'android-gif' : 'pl.droidsonroids.gif:android-gif-drawable:1.2.15', - 'jlatexmath-android' : 'ru.noties:jlatexmath-android:0.1.1', + 'jlatexmath-android' : 'ru.noties:jlatexmath-android:0.2.0', 'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0', 'prism4j' : 'io.noties:prism4j:2.0.0', 'debug' : 'io.noties:debug:5.0.0@jar', @@ -80,7 +80,7 @@ ext { 'dagger' : "com.google.dagger:dagger:$daggerVersion", 'picasso' : 'com.squareup.picasso:picasso:2.71828', 'glide' : 'com.github.bumptech.glide:glide:4.9.0', - 'coil' : 'io.coil-kt:coil:0.8.0' + 'coil' : 'io.coil-kt:coil:0.10.1' ] deps['annotationProcessor'] = [ diff --git a/docs/README.md b/docs/README.md index 3b80abb4..7fdda10c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -99,12 +99,7 @@ and 2 themes included: Light & Dark. It can be downloaded from [releases](ht ## # Awesome Markwon - -Applications using Markwon: - -* [Partico](https://partiko.app/) - Partiko is a censorship free social network. -* [FairNote](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) - simple and intuitive notepad app. It gives you speed and efficiency when you write notes, to-do lists, e-mails, or jot down quick ideas. -* [Boxcryptor](https://www.boxcryptor.com) - A software that adds AES-256 and RSA encryption to Dropbox, Google Drive, OneDrive and many other clouds. +
+* [Partico](https://partiko.app/) - Partiko is a censorship free social network. +* [FairNote](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) - simple and intuitive notepad app. It gives you speed and efficiency when you write notes, to-do lists, e-mails, or jot down quick ideas. +* [Boxcryptor](https://www.boxcryptor.com) - A software that adds AES-256 and RSA encryption to Dropbox, Google Drive, OneDrive and many other clouds. +* [Senstone Portable Voice Assistant](https://play.google.com/store/apps/details?id=com.senstone) - Senstone is a tiny wearable personal assistant powered by this App. It lets you capture your ideas, notes and reminders handsfree without pulling out your phone. + Extension/plugins: diff --git a/docs/docs/v4/core/configuration.md b/docs/docs/v4/core/configuration.md index 23a32fb0..e012dd33 100644 --- a/docs/docs/v4/core/configuration.md +++ b/docs/docs/v4/core/configuration.md @@ -5,7 +5,7 @@ These are _configurable_ properties: * `AsyncDrawableLoader` (back here since ) * `SyntaxHighlight` * `LinkResolver` (since , before — `LinkSpan.Resolver`) -* `UrlProcessor` +* `ImageDestinationProcessor` (since , before — `UrlProcessor`) * `ImageSizeResolver` :::tip @@ -36,10 +36,11 @@ final Markwon markwon = Markwon.builder(context) .build(); ``` -Currently `Markwon` provides 3 implementations for loading images: +Currently `Markwon` provides 4 implementations for loading images: * [markwon implementation](/docs/v4/image/) with SVG, GIF, data uri and android_assets support * [based on Picasso](/docs/v4/image-picasso/) * [based on Glide](/docs/v4/image-glide/) +* [base on Coil](/docs/v4/image-coil/) ## SyntaxHighlight @@ -87,32 +88,32 @@ if there is none registered. if you wish to register own instance of a `Movement apply it directly to a TextView or use [MovementMethodPlugin](/docs/v4/core/movement-method-plugin.md) ::: -## UrlProcessor +## ImageDestinationProcessor -Process URLs in your markdown (for links and images). If not provided explicitly, +Process destinations (URLs) of images in your markdown. If not provided explicitly, default **no-op** implementation will be used, which does not modify URLs (keeping them as-is). `Markwon` provides 2 implementations of `UrlProcessor`: -* `UrlProcessorRelativeToAbsolute` -* `UrlProcessorAndroidAssets` +* `ImageDestinationProcessorRelativeToAbsolute` +* `ImageDestinationProcessorAssets` -### UrlProcessorRelativeToAbsolute +### ImageDestinationProcessorRelativeToAbsolute -`UrlProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is -defined like this: `![img](./art/image.JPG)` and `UrlProcessorRelativeToAbsolute` +`ImageDestinationProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is +defined like this: `![img](./art/image.JPG)` and `ImageDestinationProcessorRelativeToAbsolute` is created with `https://github.com/noties/Markwon/raw/master/` as the base: -`new UrlProcessorRelativeToAbsolute("https://github.com/noties/Markwon/raw/master/")`, +`new ImageDestinationProcessorRelativeToAbsolute("https://github.com/noties/Markwon/raw/master/")`, then final image will have `https://github.com/noties/Markwon/raw/master/art/image.JPG` as the destination. -### UrlProcessorAndroidAssets +### ImageDestinationProcessorAssets -`UrlProcessorAndroidAssets` can be used to make processed links to point to Android assets folder. +`ImageDestinationProcessorAssets` can be used to make processed destinations to point to Android assets folder. So an image: `![img](./art/image.JPG)` will have `file:///android_asset/art/image.JPG` as the destination. :::tip -Please note that `UrlProcessorAndroidAssets` will process only URLs that have no `scheme` information, +Please note that `ImageDestinationProcessorAssets` will process only URLs that have no `scheme` information, so a `./art/image.png` will become `file:///android_asset/art/image.JPG` whilst `https://so.me/where.png` will be kept as-is. ::: diff --git a/gradle.properties b/gradle.properties index 0e222c26..559ebd01 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.3.1 +VERSION_NAME=4.4.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 cd277e03..326a70c2 100644 --- a/markwon-core/src/main/java/io/noties/markwon/Markwon.java +++ b/markwon-core/src/main/java/io/noties/markwon/Markwon.java @@ -192,6 +192,17 @@ public interface Builder { @NonNull Builder usePlugins(@NonNull Iterable plugins); + /** + * Control if small chunks of non-finished markdown sentences (for example, a single `*` character) + * should be displayed/rendered as raw input instead of an empty string. + *

+ * Since 4.4.0 {@code true} by default, versions prior - {@code false} + * + * @since 4.4.0 + */ + @NonNull + Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty); + @NonNull Markwon build(); } 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 2ae70e18..ae0f2f9d 100644 --- a/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java +++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonBuilderImpl.java @@ -27,6 +27,9 @@ class MarkwonBuilderImpl implements Markwon.Builder { private Markwon.TextSetter textSetter; + // @since 4.4.0 + private boolean fallbackToRawInputWhenEmpty = true; + MarkwonBuilderImpl(@NonNull Context context) { this.context = context; } @@ -71,6 +74,13 @@ public Markwon.Builder usePlugins(@NonNull Iterable plu return this; } + @NonNull + @Override + public Markwon.Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty) { + this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty; + return this; + } + @NonNull @Override public Markwon build() { @@ -114,7 +124,8 @@ public Markwon build() { parserBuilder.build(), visitorFactory, configuration, - Collections.unmodifiableList(plugins) + Collections.unmodifiableList(plugins), + fallbackToRawInputWhenEmpty ); } diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonConfiguration.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonConfiguration.java index 5b22623f..9b37e89f 100644 --- a/markwon-core/src/main/java/io/noties/markwon/MarkwonConfiguration.java +++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonConfiguration.java @@ -6,15 +6,13 @@ import io.noties.markwon.image.AsyncDrawableLoader; import io.noties.markwon.image.ImageSizeResolver; import io.noties.markwon.image.ImageSizeResolverDef; +import io.noties.markwon.image.destination.ImageDestinationProcessor; import io.noties.markwon.syntax.SyntaxHighlight; import io.noties.markwon.syntax.SyntaxHighlightNoOp; -import io.noties.markwon.urlprocessor.UrlProcessor; -import io.noties.markwon.urlprocessor.UrlProcessorNoOp; /** * since 3.0.0 renamed `SpannableConfiguration` -> `MarkwonConfiguration` */ -@SuppressWarnings("WeakerAccess") public class MarkwonConfiguration { @NonNull @@ -26,7 +24,8 @@ public static Builder builder() { private final AsyncDrawableLoader asyncDrawableLoader; private final SyntaxHighlight syntaxHighlight; private final LinkResolver linkResolver; - private final UrlProcessor urlProcessor; + // @since 4.4.0 + private final ImageDestinationProcessor imageDestinationProcessor; private final ImageSizeResolver imageSizeResolver; // @since 3.0.0 @@ -37,7 +36,7 @@ private MarkwonConfiguration(@NonNull Builder builder) { this.asyncDrawableLoader = builder.asyncDrawableLoader; this.syntaxHighlight = builder.syntaxHighlight; this.linkResolver = builder.linkResolver; - this.urlProcessor = builder.urlProcessor; + this.imageDestinationProcessor = builder.imageDestinationProcessor; this.imageSizeResolver = builder.imageSizeResolver; this.spansFactory = builder.spansFactory; } @@ -62,9 +61,12 @@ public LinkResolver linkResolver() { return linkResolver; } + /** + * @since 4.4.0 + */ @NonNull - public UrlProcessor urlProcessor() { - return urlProcessor; + public ImageDestinationProcessor imageDestinationProcessor() { + return imageDestinationProcessor; } @NonNull @@ -87,7 +89,8 @@ public static class Builder { private AsyncDrawableLoader asyncDrawableLoader; private SyntaxHighlight syntaxHighlight; private LinkResolver linkResolver; - private UrlProcessor urlProcessor; + // @since 4.4.0 + private ImageDestinationProcessor imageDestinationProcessor; private ImageSizeResolver imageSizeResolver; private MarkwonSpansFactory spansFactory; @@ -115,9 +118,12 @@ public Builder linkResolver(@NonNull LinkResolver linkResolver) { return this; } + /** + * @since 4.4.0 + */ @NonNull - public Builder urlProcessor(@NonNull UrlProcessor urlProcessor) { - this.urlProcessor = urlProcessor; + public Builder imageDestinationProcessor(@NonNull ImageDestinationProcessor imageDestinationProcessor) { + this.imageDestinationProcessor = imageDestinationProcessor; return this; } @@ -151,8 +157,9 @@ public MarkwonConfiguration build( linkResolver = new LinkResolverDef(); } - if (urlProcessor == null) { - urlProcessor = new UrlProcessorNoOp(); + // @since 4.4.0 + if (imageDestinationProcessor == null) { + imageDestinationProcessor = ImageDestinationProcessor.noOp(); } if (imageSizeResolver == null) { 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 5aced55c..1e0d7930 100644 --- a/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java +++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonImpl.java @@ -1,6 +1,8 @@ package io.noties.markwon; +import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.TextUtils; import android.widget.TextView; import androidx.annotation.NonNull; @@ -28,19 +30,25 @@ class MarkwonImpl extends Markwon { @Nullable private final TextSetter textSetter; + // @since 4.4.0 + private final boolean fallbackToRawInputWhenEmpty; + MarkwonImpl( @NonNull TextView.BufferType bufferType, @Nullable TextSetter textSetter, @NonNull Parser parser, @NonNull MarkwonVisitorFactory visitorFactory, @NonNull MarkwonConfiguration configuration, - @NonNull List plugins) { + @NonNull List plugins, + boolean fallbackToRawInputWhenEmpty + ) { this.bufferType = bufferType; this.textSetter = textSetter; this.parser = parser; this.visitorFactory = visitorFactory; this.configuration = configuration; this.plugins = plugins; + this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty; } @NonNull @@ -86,7 +94,18 @@ public Spanned render(@NonNull Node node) { @NonNull @Override public Spanned toMarkdown(@NonNull String input) { - return render(parse(input)); + final Spanned spanned = render(parse(input)); + + // @since 4.4.0 + // if spanned is empty, we are configured to use raw input and input is not empty + if (TextUtils.isEmpty(spanned) + && fallbackToRawInputWhenEmpty + && !TextUtils.isEmpty(input)) { + // let's use SpannableStringBuilder in order to keep backward-compatibility + return new SpannableStringBuilder(input); + } + + return spanned; } @Override diff --git a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java index 29c63a2a..1c576e7f 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java @@ -1,5 +1,6 @@ package io.noties.markwon.core; +import android.text.Spannable; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.widget.TextView; @@ -8,6 +9,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import org.commonmark.node.Block; import org.commonmark.node.BlockQuote; import org.commonmark.node.BulletList; import org.commonmark.node.Code; @@ -15,6 +17,7 @@ import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.HardLineBreak; import org.commonmark.node.Heading; +import org.commonmark.node.HtmlBlock; import org.commonmark.node.Image; import org.commonmark.node.IndentedCodeBlock; import org.commonmark.node.Link; @@ -29,7 +32,10 @@ import org.commonmark.node.ThematicBreak; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.MarkwonConfiguration; @@ -48,6 +54,7 @@ import io.noties.markwon.core.factory.StrongEmphasisSpanFactory; import io.noties.markwon.core.factory.ThematicBreakSpanFactory; import io.noties.markwon.core.spans.OrderedListItemSpan; +import io.noties.markwon.core.spans.TextViewSpan; import io.noties.markwon.image.ImageProps; /** @@ -88,6 +95,23 @@ public static CorePlugin create() { return new CorePlugin(); } + /** + * @return a set with enabled by default block types + * @since 4.4.0 + */ + @NonNull + public static Set> enabledBlockTypes() { + return new HashSet<>(Arrays.asList( + BlockQuote.class, + Heading.class, + FencedCodeBlock.class, + HtmlBlock.class, + ThematicBreak.class, + ListBlock.class, + IndentedCodeBlock.class + )); + } + // @since 4.0.0 private final List onTextAddedListeners = new ArrayList<>(0); @@ -150,6 +174,13 @@ public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) @Override public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { OrderedListItemSpan.measure(textView, markdown); + + // @since 4.4.0 + // we do not break API compatibility, instead we introduce the `instance of` check + if (markdown instanceof Spannable) { + final Spannable spannable = (Spannable) markdown; + TextViewSpan.applyTo(spannable, textView); + } } @Override @@ -289,7 +320,7 @@ public void visit(@NonNull MarkwonVisitor visitor, @NonNull Image image) { final boolean link = parent instanceof Link; final String destination = configuration - .urlProcessor() + .imageDestinationProcessor() .process(image.getDestination()); final RenderProps props = visitor.renderProps(); @@ -493,8 +524,7 @@ public void visit(@NonNull MarkwonVisitor visitor, @NonNull Link link) { final int length = visitor.length(); visitor.visitChildren(link); - final MarkwonConfiguration configuration = visitor.configuration(); - final String destination = configuration.urlProcessor().process(link.getDestination()); + final String destination = link.getDestination(); CoreProps.LINK_DESTINATION.set(visitor.renderProps(), destination); diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java new file mode 100644 index 00000000..e4e2e57b --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextLayoutSpan.java @@ -0,0 +1,70 @@ +package io.noties.markwon.core.spans; + +import android.text.Layout; +import android.text.Spannable; +import android.text.Spanned; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; + +/** + * @since 4.4.0 + */ +public class TextLayoutSpan { + + /** + * @see #applyTo(Spannable, Layout) + */ + @Nullable + public static Layout layoutOf(@NonNull CharSequence cs) { + if (cs instanceof Spanned) { + return layoutOf((Spanned) cs); + } + return null; + } + + @Nullable + public static Layout layoutOf(@NonNull Spanned spanned) { + final TextLayoutSpan[] spans = spanned.getSpans( + 0, + spanned.length(), + TextLayoutSpan.class + ); + return spans != null && spans.length > 0 + ? spans[0].layout() + : null; + } + + public static void applyTo(@NonNull Spannable spannable, @NonNull Layout layout) { + + // remove all current ones (only one should be present) + final TextLayoutSpan[] spans = spannable.getSpans(0, spannable.length(), TextLayoutSpan.class); + if (spans != null) { + for (TextLayoutSpan span : spans) { + spannable.removeSpan(span); + } + } + + final TextLayoutSpan span = new TextLayoutSpan(layout); + spannable.setSpan( + span, + 0, + spannable.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE + ); + } + + private final WeakReference reference; + + @SuppressWarnings("WeakerAccess") + TextLayoutSpan(@NonNull Layout layout) { + this.reference = new WeakReference<>(layout); + } + + @Nullable + public Layout layout() { + return reference.get(); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java new file mode 100644 index 00000000..71db81b8 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/core/spans/TextViewSpan.java @@ -0,0 +1,64 @@ +package io.noties.markwon.core.spans; + +import android.text.Spannable; +import android.text.Spanned; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.ref.WeakReference; + +/** + * A special span that allows to obtain {@code TextView} in which spans are displayed + * + * @since 4.4.0 + */ +public class TextViewSpan { + + @Nullable + public static TextView textViewOf(@NonNull CharSequence cs) { + if (cs instanceof Spanned) { + return textViewOf((Spanned) cs); + } + return null; + } + + @Nullable + public static TextView textViewOf(@NonNull Spanned spanned) { + final TextViewSpan[] spans = spanned.getSpans(0, spanned.length(), TextViewSpan.class); + return spans != null && spans.length > 0 + ? spans[0].textView() + : null; + } + + public static void applyTo(@NonNull Spannable spannable, @NonNull TextView textView) { + + final TextViewSpan[] spans = spannable.getSpans(0, spannable.length(), TextViewSpan.class); + if (spans != null) { + for (TextViewSpan span : spans) { + spannable.removeSpan(span); + } + } + + final TextViewSpan span = new TextViewSpan(textView); + // `SPAN_INCLUSIVE_INCLUSIVE` to persist in case of possible text change (deletion, etc) + spannable.setSpan( + span, + 0, + spannable.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE + ); + } + + private final WeakReference reference; + + public TextViewSpan(@NonNull TextView textView) { + this.reference = new WeakReference<>(textView); + } + + @Nullable + public TextView textView() { + return reference.get(); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java index 915adf8d..27e43720 100644 --- a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java +++ b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawableSpan.java @@ -14,6 +14,7 @@ import java.lang.annotation.RetentionPolicy; import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.utils.SpanUtils; @SuppressWarnings("WeakerAccess") public class AsyncDrawableSpan extends ReplacementSpan { @@ -99,7 +100,11 @@ public void draw( int bottom, @NonNull Paint paint) { - drawable.initWithKnownDimensions(canvas.getWidth(), paint.getTextSize()); + // @since 4.4.0 use SpanUtils instead of `canvas.getWidth` + drawable.initWithKnownDimensions( + SpanUtils.width(canvas, text), + paint.getTextSize() + ); final AsyncDrawable drawable = this.drawable; diff --git a/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessor.java b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessor.java new file mode 100644 index 00000000..ec70f8f3 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessor.java @@ -0,0 +1,27 @@ +package io.noties.markwon.image.destination; + +import androidx.annotation.NonNull; + +/** + * Process destination of image nodes + * + * @since 4.4.0 + */ +public abstract class ImageDestinationProcessor { + @NonNull + public abstract String process(@NonNull String destination); + + @NonNull + public static ImageDestinationProcessor noOp() { + return new NoOp(); + } + + private static class NoOp extends ImageDestinationProcessor { + + @NonNull + @Override + public String process(@NonNull String destination) { + return destination; + } + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssets.java b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssets.java new file mode 100644 index 00000000..b52a04ca --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssets.java @@ -0,0 +1,59 @@ +package io.noties.markwon.image.destination; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * {@link ImageDestinationProcessor} that treats all destinations without scheme + * information as pointing to the {@code assets} folder of an application. Please note that this + * processor only adds required {@code file:///android_asset/} prefix to destinations and + * actual image loading must take that into account (implement this functionality). + *

+ * {@code FileSchemeHandler} from the {@code image} module supports asset images when created with + * {@code createWithAssets} factory method + * + * @since 4.4.0 + */ +public class ImageDestinationProcessorAssets extends ImageDestinationProcessor { + + @NonNull + public static ImageDestinationProcessorAssets create(@Nullable ImageDestinationProcessor parent) { + return new ImageDestinationProcessorAssets(parent); + } + + static final String MOCK = "https://android.asset/"; + static final String BASE = "file:///android_asset/"; + + private final ImageDestinationProcessorRelativeToAbsolute assetsProcessor + = new ImageDestinationProcessorRelativeToAbsolute(MOCK); + + private final ImageDestinationProcessor processor; + + public ImageDestinationProcessorAssets() { + this(null); + } + + public ImageDestinationProcessorAssets(@Nullable ImageDestinationProcessor parent) { + this.processor = parent; + } + + @NonNull + @Override + public String process(@NonNull String destination) { + final String out; + final Uri uri = Uri.parse(destination); + if (TextUtils.isEmpty(uri.getScheme())) { + out = assetsProcessor.process(destination).replace(MOCK, BASE); + } else { + if (processor != null) { + out = processor.process(destination); + } else { + out = destination; + } + } + return out; + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsolute.java b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsolute.java similarity index 54% rename from markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsolute.java rename to markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsolute.java index 99a19226..23f1c706 100644 --- a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsolute.java +++ b/markwon-core/src/main/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsolute.java @@ -1,4 +1,4 @@ -package io.noties.markwon.urlprocessor; +package io.noties.markwon.image.destination; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -6,15 +6,30 @@ import java.net.MalformedURLException; import java.net.URL; -@SuppressWarnings("WeakerAccess") -public class UrlProcessorRelativeToAbsolute implements UrlProcessor { +/** + * @since 4.4.0 + */ +public class ImageDestinationProcessorRelativeToAbsolute extends ImageDestinationProcessor { + + @NonNull + public static ImageDestinationProcessorRelativeToAbsolute create(@NonNull String base) { + return new ImageDestinationProcessorRelativeToAbsolute(base); + } + + public static ImageDestinationProcessorRelativeToAbsolute create(@NonNull URL base) { + return new ImageDestinationProcessorRelativeToAbsolute(base); + } private final URL base; - public UrlProcessorRelativeToAbsolute(@NonNull String base) { + public ImageDestinationProcessorRelativeToAbsolute(@NonNull String base) { this.base = obtain(base); } + public ImageDestinationProcessorRelativeToAbsolute(@NonNull URL base) { + this.base = base; + } + @NonNull @Override public String process(@NonNull String destination) { diff --git a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessor.java b/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessor.java deleted file mode 100644 index b49585e5..00000000 --- a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessor.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.noties.markwon.urlprocessor; - -import androidx.annotation.NonNull; - -public interface UrlProcessor { - @NonNull - String process(@NonNull String destination); -} diff --git a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssets.java b/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssets.java deleted file mode 100644 index bd3c74cb..00000000 --- a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssets.java +++ /dev/null @@ -1,49 +0,0 @@ -package io.noties.markwon.urlprocessor; - -import android.net.Uri; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Processor that will assume that an URL without scheme points to android assets folder. - * URL with a scheme will be processed by {@link #processor} (if it is specified) or returned `as-is`. - */ -@SuppressWarnings({"unused", "WeakerAccess"}) -public class UrlProcessorAndroidAssets implements UrlProcessor { - - - static final String MOCK = "https://android.asset/"; - static final String BASE = "file:///android_asset/"; - - private final UrlProcessorRelativeToAbsolute assetsProcessor - = new UrlProcessorRelativeToAbsolute(MOCK); - - private final UrlProcessor processor; - - public UrlProcessorAndroidAssets() { - this(null); - } - - public UrlProcessorAndroidAssets(@Nullable UrlProcessor parent) { - this.processor = parent; - } - - @NonNull - @Override - public String process(@NonNull String destination) { - final String out; - final Uri uri = Uri.parse(destination); - if (TextUtils.isEmpty(uri.getScheme())) { - out = assetsProcessor.process(destination).replace(MOCK, BASE); - } else { - if (processor != null) { - out = processor.process(destination); - } else { - out = destination; - } - } - return out; - } -} diff --git a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorNoOp.java b/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorNoOp.java deleted file mode 100644 index 1bc15a88..00000000 --- a/markwon-core/src/main/java/io/noties/markwon/urlprocessor/UrlProcessorNoOp.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.noties.markwon.urlprocessor; - -import androidx.annotation.NonNull; - -public class UrlProcessorNoOp implements UrlProcessor { - @NonNull - @Override - public String process(@NonNull String destination) { - return destination; - } -} diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java b/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java index 8b579356..fe9d48dc 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/Dip.java @@ -18,6 +18,7 @@ public static Dip create(float density) { private final float density; + @SuppressWarnings("WeakerAccess") public Dip(float density) { this.density = density; } diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java index ffa2bf86..474021e7 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/DumpNodes.java @@ -12,6 +12,7 @@ import java.lang.reflect.Proxy; // utility class to print parsed Nodes hierarchy +@SuppressWarnings({"unused", "WeakerAccess"}) public abstract class DumpNodes { public interface NodeProcessor { diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java new file mode 100644 index 00000000..32c763e4 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/utils/LayoutUtils.java @@ -0,0 +1,72 @@ +package io.noties.markwon.utils; + +import android.os.Build; +import android.text.Layout; + +import androidx.annotation.NonNull; + +/** + * @since 4.4.0 + */ +public abstract class LayoutUtils { + + private static final float DEFAULT_EXTRA = 0F; + private static final float DEFAULT_MULTIPLIER = 1F; + + public static int getLineBottomWithoutPaddingAndSpacing( + @NonNull Layout layout, + int line + ) { + + final int bottom = layout.getLineBottom(line); + final boolean lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + final boolean isSpanLastLine = line == (layout.getLineCount() - 1); + + final int lineBottom; + final float lineSpacingExtra = layout.getSpacingAdd(); + final float lineSpacingMultiplier = layout.getSpacingMultiplier(); + + // simplified check + final boolean hasLineSpacing = lineSpacingExtra != DEFAULT_EXTRA + || lineSpacingMultiplier != DEFAULT_MULTIPLIER; + + if (!hasLineSpacing + || (isSpanLastLine && lastLineSpacingNotAdded)) { + lineBottom = bottom; + } else { + final float extra; + if (Float.compare(DEFAULT_MULTIPLIER, lineSpacingMultiplier) != 0) { + final int lineHeight = getLineHeight(layout, line); + extra = lineHeight - + ((lineHeight - lineSpacingExtra) / lineSpacingMultiplier); + } else { + extra = lineSpacingExtra; + } + lineBottom = (int) (bottom - extra + .5F); + } + + // check if it is the last line that span is occupying **and** that this line is the last + // one in TextView + if (isSpanLastLine + && (line == layout.getLineCount() - 1)) { + return lineBottom - layout.getBottomPadding(); + } + + return lineBottom; + } + + public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) { + final int top = layout.getLineTop(line); + if (line == 0) { + return top - layout.getTopPadding(); + } + return top; + } + + public static int getLineHeight(@NonNull Layout layout, int line) { + return layout.getLineTop(line + 1) - layout.getLineTop(line); + } + + private LayoutUtils() { + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java index 072d2ccf..601ad470 100644 --- a/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java +++ b/markwon-core/src/main/java/io/noties/markwon/utils/LeadingMarginUtils.java @@ -4,7 +4,6 @@ public abstract class LeadingMarginUtils { - @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean selfStart(int start, CharSequence text, Object span) { return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start; } diff --git a/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java b/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java new file mode 100644 index 00000000..baf0449a --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/utils/SpanUtils.java @@ -0,0 +1,42 @@ +package io.noties.markwon.utils; + +import android.graphics.Canvas; +import android.text.Layout; +import android.text.Spanned; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import io.noties.markwon.core.spans.TextLayoutSpan; +import io.noties.markwon.core.spans.TextViewSpan; + +/** + * @since 4.4.0 + */ +public abstract class SpanUtils { + + public static int width(@NonNull Canvas canvas, @NonNull CharSequence cs) { + // Layout + // TextView + // canvas + + if (cs instanceof Spanned) { + final Spanned spanned = (Spanned) cs; + + // if we are displayed with layout information -> use it + final Layout layout = TextLayoutSpan.layoutOf(spanned); + if (layout != null) { + return layout.getWidth(); + } + + // if we have TextView -> obtain width from it (exclude padding) + final TextView textView = TextViewSpan.textViewOf(spanned); + if (textView != null) { + return textView.getWidth() - textView.getPaddingLeft() - textView.getPaddingRight(); + } + } + + // else just use canvas width + return canvas.getWidth(); + } +} 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 0d9024d4..599e24d5 100644 --- a/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/MarkwonImplTest.java @@ -1,6 +1,7 @@ package io.noties.markwon; import android.text.Spanned; +import android.text.TextUtils; import android.widget.TextView; import org.commonmark.node.Node; @@ -50,7 +51,9 @@ public void parse_calls_plugin_process_markdown() { mock(Parser.class), mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - Collections.singletonList(plugin)); + Collections.singletonList(plugin), + true + ); impl.parse("whatever"); @@ -74,7 +77,9 @@ public void parse_markwon_processed() { parser, mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - Arrays.asList(first, second)); + Arrays.asList(first, second), + true + ); impl.parse("zero"); @@ -102,7 +107,9 @@ public void render_calls_plugins() { mock(Parser.class), visitorFactory, mock(MarkwonConfiguration.class), - Collections.singletonList(plugin)); + Collections.singletonList(plugin), + true + ); when(visitorFactory.create()).thenReturn(visitor); when(visitor.builder()).thenReturn(builder); @@ -149,7 +156,9 @@ public void render_clears_visitor() { mock(Parser.class), visitorFactory, mock(MarkwonConfiguration.class), - Collections.emptyList()); + Collections.emptyList(), + true + ); impl.render(mock(Node.class)); @@ -185,7 +194,9 @@ public Object answer(InvocationOnMock invocation) { mock(Parser.class), visitorFactory, mock(MarkwonConfiguration.class), - Collections.singletonList(plugin)); + Collections.singletonList(plugin), + true + ); final AtomicBoolean flag = new AtomicBoolean(false); final Node node = mock(Node.class); @@ -224,7 +235,9 @@ public void set_parsed_markdown() { mock(Parser.class), mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), mock(MarkwonConfiguration.class), - Collections.singletonList(plugin)); + Collections.singletonList(plugin), + true + ); final TextView textView = mock(TextView.class); final AtomicBoolean flag = new AtomicBoolean(false); @@ -272,7 +285,9 @@ final class Second extends AbstractMarkwonPlugin { mock(Parser.class), mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - plugins); + plugins, + true + ); assertTrue("First", impl.hasPlugin(First.class)); assertFalse("Second", impl.hasPlugin(Second.class)); @@ -295,7 +310,9 @@ public void text_setter() { mock(Parser.class), mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - Collections.singletonList(plugin)); + Collections.singletonList(plugin), + true + ); final TextView textView = mock(TextView.class); final Spanned spanned = mock(Spanned.class); @@ -339,7 +356,9 @@ final class NotPresent extends AbstractMarkwonPlugin { mock(Parser.class), mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - plugins); + plugins, + true + ); // should be returned assertNotNull(impl.requirePlugin(MarkwonPlugin.class)); @@ -370,7 +389,9 @@ public void plugins_unmodifiable() { mock(Parser.class), mock(MarkwonVisitorFactory.class), mock(MarkwonConfiguration.class), - plugins); + plugins, + true + ); final List list = impl.getPlugins(); @@ -385,4 +406,42 @@ public void plugins_unmodifiable() { assertTrue(e.getMessage(), true); } } + + @Test + public void fallback_to_raw() { + final String md = "*"; + + final MarkwonImpl impl = new MarkwonImpl( + TextView.BufferType.SPANNABLE, + null, + mock(Parser.class, RETURNS_MOCKS), + // it must be sufficient to just return mocks and thus empty rendering result + mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), + mock(MarkwonConfiguration.class), + Collections.emptyList(), + true + ); + + final Spanned spanned = impl.toMarkdown(md); + assertEquals(md, spanned.toString()); + } + + @Test + public void fallback_to_raw_false() { + final String md = "*"; + + final MarkwonImpl impl = new MarkwonImpl( + TextView.BufferType.SPANNABLE, + null, + mock(Parser.class, RETURNS_MOCKS), + // it must be sufficient to just return mocks and thus empty rendering result + mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), + mock(MarkwonConfiguration.class), + Collections.emptyList(), + false + ); + + final Spanned spanned = impl.toMarkdown(md); + assertTrue(spanned.toString(), TextUtils.isEmpty(spanned)); + } } \ No newline at end of file diff --git a/markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssetsTest.java b/markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssetsTest.java similarity index 77% rename from markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssetsTest.java rename to markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssetsTest.java index 4129bfb8..4ab57e6b 100644 --- a/markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorAndroidAssetsTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorAssetsTest.java @@ -1,4 +1,4 @@ -package io.noties.markwon.urlprocessor; +package io.noties.markwon.image.destination; import org.junit.Before; import org.junit.Test; @@ -6,18 +6,18 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import static io.noties.markwon.image.destination.ImageDestinationProcessorAssets.BASE; import static org.junit.Assert.assertEquals; -import static io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets.BASE; @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) -public class UrlProcessorAndroidAssetsTest { +public class ImageDestinationProcessorAssetsTest { - private UrlProcessorAndroidAssets processor; + private ImageDestinationProcessorAssets processor; @Before public void before() { - processor = new UrlProcessorAndroidAssets(); + processor = new ImageDestinationProcessorAssets(); } @Test diff --git a/markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsoluteTest.java b/markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsoluteTest.java similarity index 59% rename from markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsoluteTest.java rename to markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsoluteTest.java index afa55b33..36560fb6 100644 --- a/markwon-core/src/test/java/io/noties/markwon/urlprocessor/UrlProcessorRelativeToAbsoluteTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/image/destination/ImageDestinationProcessorRelativeToAbsoluteTest.java @@ -1,4 +1,4 @@ -package io.noties.markwon.urlprocessor; +package io.noties.markwon.image.destination; import org.junit.Test; import org.junit.runner.RunWith; @@ -9,39 +9,39 @@ @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) -public class UrlProcessorRelativeToAbsoluteTest { +public class ImageDestinationProcessorRelativeToAbsoluteTest { @Test public void malformed_base_do_not_process() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("!@#$%^&*("); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("!@#$%^&*("); final String destination = "../hey.there.html"; assertEquals(destination, processor.process(destination)); } @Test public void access_root() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("https://ro.ot/hello/"); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("https://ro.ot/hello/"); final String url = "/index.html"; assertEquals("https://ro.ot/index.html", processor.process(url)); } @Test public void access_same_directory() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("https://ro.ot/hello/"); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("https://ro.ot/hello/"); final String url = "./.htaccess"; assertEquals("https://ro.ot/hello/.htaccess", processor.process(url)); } @Test public void asset_directory_up() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/second/"); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/second/"); final String url = "../cat.JPG"; assertEquals("http://ro.ot/first/cat.JPG", processor.process(url)); } @Test public void change_directory_inside_destination() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/"); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/"); final String url = "../first/../second/./thi.rd"; assertEquals( "http://ro.ot/second/thi.rd", @@ -51,7 +51,7 @@ public void change_directory_inside_destination() { @Test public void with_query_arguments() { - final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/"); + final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/"); final String url = "../index.php?ROOT=1"; assertEquals( "http://ro.ot/index.php?ROOT=1", diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java index 87456358..dd8a606e 100644 --- a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java @@ -458,8 +458,7 @@ private JLatexMathDrawable createBlockDrawable(@NonNull JLatextAsyncDrawable dra final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) .textSize(theme.blockTextSize()) - .align(theme.blockHorizontalAlignment()) - .fitCanvas(theme.blockFitCanvas()); + .align(theme.blockHorizontalAlignment()); if (backgroundProvider != null) { builder.background(backgroundProvider.provide()); @@ -489,8 +488,7 @@ private JLatexMathDrawable createInlineDrawable(@NonNull JLatextAsyncDrawable dr final int color = theme.inlineTextColor(); final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) - .textSize(theme.inlineTextSize()) - .fitCanvas(false); + .textSize(theme.inlineTextSize()); if (backgroundProvider != null) { builder.background(backgroundProvider.provide()); @@ -530,7 +528,20 @@ private static class InlineImageSizeResolver extends ImageSizeResolver { @NonNull @Override public Rect resolveImageSize(@NonNull AsyncDrawable drawable) { - return drawable.getResult().getBounds(); + + // @since 4.4.0 resolve inline size (scale down if exceed available width) + final Rect imageBounds = drawable.getResult().getBounds(); + final int canvasWidth = drawable.getLastKnownCanvasWidth(); + final int w = imageBounds.width(); + + if (w > canvasWidth) { + // here we must scale it down (keeping the ratio) + final float ratio = (float) w / imageBounds.height(); + final int h = (int) (canvasWidth / ratio + .5F); + return new Rect(0, 0, canvasWidth, h); + } + + return imageBounds; } } } diff --git a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java index 0248c4ef..c4fd8204 100644 --- a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java +++ b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TableRowSpan.java @@ -4,7 +4,10 @@ import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; +import android.graphics.drawable.Drawable; import android.text.Layout; +import android.text.Spannable; +import android.text.SpannableString; import android.text.Spanned; import android.text.StaticLayout; import android.text.TextPaint; @@ -20,7 +23,11 @@ import java.util.ArrayList; import java.util.List; +import io.noties.markwon.core.spans.TextLayoutSpan; +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.AsyncDrawableSpan; import io.noties.markwon.utils.LeadingMarginUtils; +import io.noties.markwon.utils.SpanUtils; public class TableRowSpan extends ReplacementSpan { @@ -67,7 +74,7 @@ public String toString() { private final TableTheme theme; private final List cells; - private final List layouts; + private final List layouts; private final TextPaint textPaint; private final boolean header; private final boolean odd; @@ -108,7 +115,7 @@ public int getSize( if (fm != null) { int max = 0; - for (StaticLayout layout : layouts) { + for (Layout layout : layouts) { final int height = layout.getHeight(); if (height > max) { max = height; @@ -144,8 +151,9 @@ public void draw( int bottom, @NonNull Paint p) { - if (recreateLayouts(canvas.getWidth())) { - width = canvas.getWidth(); + final int spanWidth = SpanUtils.width(canvas, text); + if (recreateLayouts(spanWidth)) { + width = spanWidth; // @since 4.3.1 it's important to cast to TextPaint in order to display links, etc if (p instanceof TextPaint) { // there must be a reason why this method receives Paint instead of TextPaint... @@ -236,7 +244,7 @@ public void draw( final int borderTop = isFirstTableRow ? borderWidth : 0; final int borderBottom = bottom - top - borderWidth; - StaticLayout layout; + Layout layout; for (int i = 0; i < size; i++) { layout = layouts.get(i); final int save = canvas.save(); @@ -293,20 +301,76 @@ private void makeNewLayouts() { final int w = (width / columns) - padding; this.layouts.clear(); - Cell cell; - StaticLayout layout; + for (int i = 0, size = cells.size(); i < size; i++) { - cell = cells.get(i); - layout = new StaticLayout( - cell.text, - textPaint, - w, - alignment(cell.alignment), - 1.F, - .0F, - false - ); - layouts.add(layout); + makeLayout(i, w, cells.get(i)); + } + } + + private void makeLayout(final int index, final int width, @NonNull final Cell cell) { + + final Runnable recreate = new Runnable() { + @Override + public void run() { + final Invalidator invalidator = TableRowSpan.this.invalidator; + if (invalidator != null) { + layouts.remove(index); + makeLayout(index, width, cell); + invalidator.invalidate(); + } + } + }; + + final Spannable spannable; + + if (cell.text instanceof Spannable) { + spannable = (Spannable) cell.text; + } else { + spannable = new SpannableString(cell.text); + } + + final Layout layout = new StaticLayout( + spannable, + textPaint, + width, + alignment(cell.alignment), + 1.0F, + 0.0F, + false + ); + + // @since 4.4.0 + TextLayoutSpan.applyTo(spannable, layout); + + // @since 4.4.0 + scheduleAsyncDrawables(spannable, recreate); + + layouts.add(index, layout); + } + + private void scheduleAsyncDrawables(@NonNull Spannable spannable, @NonNull final Runnable recreate) { + + final AsyncDrawableSpan[] spans = spannable.getSpans(0, spannable.length(), AsyncDrawableSpan.class); + if (spans != null + && spans.length > 0) { + + for (AsyncDrawableSpan span : spans) { + + final AsyncDrawable drawable = span.getDrawable(); + + // it is absolutely crucial to check if drawable is already attached, + // otherwise we would end up with a loop + if (drawable.isAttached()) { + continue; + } + + drawable.setCallback2(new CallbackAdapter() { + @Override + public void invalidateDrawable(@NonNull Drawable who) { + recreate.run(); + } + }); + } } } @@ -330,4 +394,21 @@ private static Layout.Alignment alignment(@Alignment int alignment) { public void invalidator(@Nullable Invalidator invalidator) { this.invalidator = invalidator; } + + private static abstract class CallbackAdapter implements Drawable.Callback { + @Override + public void invalidateDrawable(@NonNull Drawable who) { + + } + + @Override + public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { + + } + + @Override + public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { + + } + } } diff --git a/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java b/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java index 48b8acb4..b074dcea 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/HtmlEmptyTagReplacement.java @@ -21,6 +21,7 @@ public static HtmlEmptyTagReplacement create() { } private static final String IMG_REPLACEMENT = "\uFFFC"; + private static final String IFRAME_REPLACEMENT = "\u00a0"; // non-breakable space /** * @return replacement for supplied startTag or null if no replacement should occur (which will @@ -44,6 +45,9 @@ public String replace(@NonNull HtmlTag tag) { } else { replacement = alt; } + } else if ("iframe".equals(name)) { + // @since 4.4.0 make iframe non-empty + replacement = IFRAME_REPLACEMENT; } else { replacement = null; } diff --git a/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java b/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java index c2c13c12..f2c2ea54 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/HtmlPlugin.java @@ -53,13 +53,16 @@ public static HtmlPlugin create(@NonNull HtmlConfigure configure) { public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; private final MarkwonHtmlRendererImpl.Builder builder; - private final MarkwonHtmlParser htmlParser; + + private MarkwonHtmlParser htmlParser; private MarkwonHtmlRenderer htmlRenderer; + // @since 4.4.0 + private HtmlEmptyTagReplacement emptyTagReplacement = new HtmlEmptyTagReplacement(); + @SuppressWarnings("WeakerAccess") HtmlPlugin() { this.builder = new MarkwonHtmlRendererImpl.Builder(); - this.htmlParser = MarkwonHtmlParserImpl.create(); } /** @@ -104,6 +107,16 @@ public HtmlPlugin excludeDefaults(boolean excludeDefaults) { return this; } + /** + * @param emptyTagReplacement {@link HtmlEmptyTagReplacement} + * @since 4.4.0 + */ + @NonNull + public HtmlPlugin emptyTagReplacement(@NonNull HtmlEmptyTagReplacement emptyTagReplacement) { + this.emptyTagReplacement = emptyTagReplacement; + return this; + } + @Override public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) { @@ -128,6 +141,7 @@ public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configu builder.addDefaultTagHandler(new HeadingHandler()); } + htmlParser = MarkwonHtmlParserImpl.create(emptyTagReplacement); htmlRenderer = builder.build(); } diff --git a/markwon-html/src/main/java/io/noties/markwon/html/tag/ImageHandler.java b/markwon-html/src/main/java/io/noties/markwon/html/tag/ImageHandler.java index ad170b0e..833c4926 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/tag/ImageHandler.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/tag/ImageHandler.java @@ -62,7 +62,7 @@ public Object getSpans( return null; } - final String destination = configuration.urlProcessor().process(src); + final String destination = configuration.imageDestinationProcessor().process(src); final ImageSize imageSize = imageSizeParser.parse(tag.attributes()); // todo: replacement text is link... as we are not at block level diff --git a/markwon-html/src/main/java/io/noties/markwon/html/tag/LinkHandler.java b/markwon-html/src/main/java/io/noties/markwon/html/tag/LinkHandler.java index fdd830cc..d5e032f7 100644 --- a/markwon-html/src/main/java/io/noties/markwon/html/tag/LinkHandler.java +++ b/markwon-html/src/main/java/io/noties/markwon/html/tag/LinkHandler.java @@ -27,7 +27,8 @@ public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull Ren CoreProps.LINK_DESTINATION.set( renderProps, - configuration.urlProcessor().process(destination)); + destination + ); return spanFactory.getSpans(configuration, renderProps); } 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 index 5d15dcfd..b71543a7 100644 --- 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 @@ -15,7 +15,6 @@ import coil.Coil; import coil.ImageLoader; -import coil.api.ImageLoaders; import coil.request.LoadRequest; import coil.request.RequestDisposable; import coil.target.Target; @@ -48,7 +47,7 @@ public static CoilImagesPlugin create(@NonNull final Context context) { @NonNull @Override public LoadRequest load(@NonNull AsyncDrawable drawable) { - return ImageLoaders.newLoadBuilder(Coil.loader(), context) + return LoadRequest.builder(context) .data(drawable.getDestination()) .build(); } @@ -57,7 +56,7 @@ public LoadRequest load(@NonNull AsyncDrawable drawable) { public void cancel(@NonNull RequestDisposable disposable) { disposable.dispose(); } - }, Coil.loader()); + }, Coil.imageLoader(context)); } @NonNull @@ -67,7 +66,7 @@ public static CoilImagesPlugin create(@NonNull final Context context, @NonNull @Override public LoadRequest load(@NonNull AsyncDrawable drawable) { - return ImageLoaders.newLoadBuilder(imageLoader, context) + return LoadRequest.builder(context) .data(drawable.getDestination()) .build(); } @@ -129,7 +128,7 @@ public void load(@NonNull AsyncDrawable drawable) { LoadRequest request = coilStore.load(drawable).newBuilder() .target(target) .build(); - RequestDisposable disposable = imageLoader.load(request); + RequestDisposable disposable = imageLoader.execute(request); cache.put(drawable, disposable); } diff --git a/markwon-image/src/main/java/io/noties/markwon/image/file/FileSchemeHandler.java b/markwon-image/src/main/java/io/noties/markwon/image/file/FileSchemeHandler.java index d7da6dc5..b29a67b6 100644 --- a/markwon-image/src/main/java/io/noties/markwon/image/file/FileSchemeHandler.java +++ b/markwon-image/src/main/java/io/noties/markwon/image/file/FileSchemeHandler.java @@ -3,11 +3,12 @@ import android.content.Context; import android.content.res.AssetManager; import android.net.Uri; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.text.TextUtils; import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; @@ -18,7 +19,6 @@ import java.util.Collections; import java.util.List; -import io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets; import io.noties.markwon.image.ImageItem; import io.noties.markwon.image.SchemeHandler; @@ -30,7 +30,7 @@ public class FileSchemeHandler extends SchemeHandler { public static final String SCHEME = "file"; /** - * @see UrlProcessorAndroidAssets + * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets */ @NonNull public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetManager) { @@ -39,7 +39,7 @@ public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetMana /** * @see #createWithAssets(AssetManager) - * @see UrlProcessorAndroidAssets + * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets * @since 4.0.0 */ @NonNull diff --git a/release-management.md b/release-management.md index ce66bb97..da2721d5 100644 --- a/release-management.md +++ b/release-management.md @@ -38,7 +38,8 @@ For example, `@since $nap` seems like a good candidate. For this a live template whenever a new API method/field/functionality-change is introduced (`snc`): ``` -@since $nap; +// semicolon with a space so this one is not accedentally replaced with release version +@since $nap ; ``` This live template would be possible to use in both inline comment and javadoc comment. diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index cda45b5d..190d2c05 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -24,7 +24,11 @@ - + + + @@ -32,6 +36,7 @@ diff --git a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java index 9b9ffdde..21ba2bad 100644 --- a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java @@ -5,6 +5,7 @@ import android.os.Bundle; import android.text.TextUtils; import android.text.style.ForegroundColorSpan; +import android.view.View; import android.widget.ScrollView; import android.widget.TextView; @@ -20,6 +21,7 @@ import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.BlockHandlerDef; +import io.noties.markwon.LinkResolverDef; import io.noties.markwon.Markwon; import io.noties.markwon.MarkwonConfiguration; import io.noties.markwon.MarkwonSpansFactory; @@ -153,7 +155,7 @@ public void configureTheme(@NonNull MarkwonTheme.Builder builder) { *

    *
  • SyntaxHighlight
  • *
  • LinkSpan.Resolver
  • - *
  • UrlProcessor
  • + *
  • ImageDestinationProcessor
  • *
  • ImageSizeResolver
  • *
*

@@ -173,12 +175,18 @@ private void linkWithMovementMethod() { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { // for example if specified destination has no scheme info, we will // _assume_ that it's network request and append HTTPS scheme - builder.urlProcessor(destination -> { - final Uri uri = Uri.parse(destination); - if (TextUtils.isEmpty(uri.getScheme())) { - return "https://" + destination; + builder.linkResolver(new LinkResolverDef() { + @Override + public void resolve(@NonNull View view, @NonNull String link) { + final String destination; + final Uri uri = Uri.parse(link); + if (TextUtils.isEmpty(uri.getScheme())) { + destination = "https://" + link; + } else { + destination = link; + } + super.resolve(view, destination); } - return destination; }); } }) @@ -434,4 +442,25 @@ private void tableOfContents() { markwon.setMarkdown(textView, md); } + +// private void code() { +// final String md = "" + +// "hello `there`!\n\n" + +// "so this, `is super duper long very very very long line that should be going further and further and further down` yep.\n\n" + +// "`okay`"; +// final Markwon markwon = Markwon.builder(this) +// .usePlugin(new AbstractMarkwonPlugin() { +// @Override +// public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { +// builder.setFactory(Code.class, new SpanFactory() { +// @Override +// public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps props) { +// return new CodeTextView.CodeSpan(); +// } +// }); +// } +// }) +// .build(); +// markwon.setMarkdown(textView, md); +// } } diff --git a/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java b/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java new file mode 100644 index 00000000..fe795b63 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/basicplugins/CodeTextView.java @@ -0,0 +1,192 @@ +package io.noties.markwon.sample.basicplugins; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Build; +import android.text.Layout; +import android.text.Spanned; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.noties.debug.Debug; + +@SuppressLint("AppCompatCustomView") +public class CodeTextView extends TextView { + + static class CodeSpan { + } + + private int paddingHorizontal; + private int paddingVertical; + + private float cornerRadius; + private float strokeWidth; + private int strokeColor; + private int backgroundColor; + + public CodeTextView(Context context) { + super(context); + init(context, null); + } + + public CodeTextView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + private void init(Context context, @Nullable AttributeSet attrs) { + paint.setColor(0xFFff0000); + paint.setStyle(Paint.Style.FILL); + } + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + + @Override + protected void onDraw(Canvas canvas) { + final Layout layout = getLayout(); + if (layout != null) { + draw(this, canvas, layout); + } + super.onDraw(canvas); + } + + private void draw( + @NonNull View view, + @NonNull Canvas canvas, + @NonNull Layout layout + ) { + + final CharSequence cs = layout.getText(); + if (!(cs instanceof Spanned)) { + return; + } + final Spanned spanned = (Spanned) cs; + + final int save = canvas.save(); + try { + canvas.translate(view.getPaddingLeft(), view.getPaddingTop()); + + // TODO: block? + // TODO: we must remove _original_ spans + // TODO: cache (attach a listener?) + // TODO: editor? + + final CodeSpan[] spans = spanned.getSpans(0, spanned.length(), CodeSpan.class); + if (spans != null && spans.length > 0) { + for (CodeSpan span : spans) { + + final int startOffset = spanned.getSpanStart(span); + final int endOffset = spanned.getSpanEnd(span); + + final int startLine = layout.getLineForOffset(startOffset); + final int endLine = layout.getLineForOffset(endOffset); + + // do we need to round them? + final float left = layout.getPrimaryHorizontal(startOffset) + + (-1 * layout.getParagraphDirection(startLine) * paddingHorizontal); + + final float right = layout.getPrimaryHorizontal(endOffset) + + (layout.getParagraphDirection(endLine) * paddingHorizontal); + + final float top = getLineTop(layout, startLine, paddingVertical); + final float bottom = getLineBottom(layout, endLine, paddingVertical); + + Debug.i(new RectF(left, top, right, bottom).toShortString()); + + if (startLine == endLine) { + canvas.drawRect(left, top, right, bottom, paint); + } else { + // draw first line (start until the lineEnd) + // draw everything in-between (startLine - endLine) + // draw last line (lineStart until the end + + canvas.drawRect( + left, + top, + layout.getLineRight(startLine), + getLineBottom(layout, startLine, paddingVertical), + paint + ); + + for (int line = startLine + 1; line < endLine; line++) { + canvas.drawRect( + layout.getLineLeft(line), + getLineTop(layout, line, paddingVertical), + layout.getLineRight(line), + getLineBottom(layout, line, paddingVertical), + paint + ); + } + + canvas.drawRect( + layout.getLineLeft(endLine), + getLineTop(layout, endLine, paddingVertical), + right, + getLineBottom(layout, endLine, paddingVertical), + paint + ); + } + } + } + } finally { + canvas.restoreToCount(save); + } + } + + private static float getLineTop(@NonNull Layout layout, int line, float padding) { + float value = layout.getLineTop(line) - padding; + if (line == 0) { + value -= layout.getTopPadding(); + } + return value; + } + + private static float getLineBottom(@NonNull Layout layout, int line, float padding) { + float value = getLineBottomWithoutSpacing(layout, line) - padding; + if (line == (layout.getLineCount() - 1)) { + value -= layout.getBottomPadding(); + } + return value; + } + + private static float getLineBottomWithoutSpacing(@NonNull Layout layout, int line) { + final float value = layout.getLineBottom(line); + + final boolean isLastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + final boolean isLastLine = line == (layout.getLineCount() - 1); + + final float lineBottomWithoutSpacing; + + final float lineSpacingExtra = layout.getSpacingAdd(); + final float lineSpacingMultiplier = layout.getSpacingMultiplier(); + + final boolean hasLineSpacing = Float.compare(lineSpacingExtra, .0F) != 0 + || Float.compare(lineSpacingMultiplier, 1F) != 0; + + if (!hasLineSpacing || isLastLine && isLastLineSpacingNotAdded) { + lineBottomWithoutSpacing = value; + } else { + final float extra; + if (Float.compare(lineSpacingMultiplier, 1F) != 0) { + final float lineHeight = getLineHeight(layout, line); + extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier; + } else { + extra = lineSpacingExtra; + } + lineBottomWithoutSpacing = value - extra; + } + + return lineBottomWithoutSpacing; + } + + private static float getLineHeight(@NonNull Layout layout, int line) { + return layout.getLineTop(line + 1) - layout.getLineTop(line); + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java b/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java index 19c6d3dd..7ce59464 100644 --- a/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java @@ -8,8 +8,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.commonmark.node.Block; +import org.commonmark.node.BlockQuote; import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import java.util.Set; + +import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.Markwon; import io.noties.markwon.core.CorePlugin; import io.noties.markwon.sample.ActivityWithMenuOptions; @@ -26,7 +32,8 @@ public MenuOptions menuOptions() { return MenuOptions.create() .add("simple", this::simple) .add("toast", this::toast) - .add("alreadyParsed", this::alreadyParsed); + .add("alreadyParsed", this::alreadyParsed) + .add("enabledBlockTypes", this::enabledBlockTypes); } @Override @@ -132,4 +139,28 @@ private void alreadyParsed() { // apply parsed markdown markwon.setParsedMarkdown(textView, spanned); } + + private void enabledBlockTypes() { + + final String md = "" + + "# Head\n\n" + + "> and disabled quote\n\n" + + "```\n" + + "yep\n" + + "```"; + + final Set> blocks = CorePlugin.enabledBlockTypes(); + blocks.remove(BlockQuote.class); + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.enabledBlockTypes(blocks); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } } 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 index 31a4370e..84a4fdb7 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java @@ -6,6 +6,7 @@ import android.text.Spanned; import android.text.TextPaint; import android.text.TextUtils; +import android.text.TextWatcher; import android.text.method.LinkMovementMethod; import android.text.style.ForegroundColorSpan; import android.text.style.MetricAffectingSpan; @@ -25,8 +26,11 @@ import java.util.List; import java.util.concurrent.Executors; +import io.noties.debug.AndroidLogDebugOutput; +import io.noties.debug.Debug; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.Markwon; +import io.noties.markwon.SoftBreakAddsNewLinePlugin; import io.noties.markwon.core.spans.EmphasisSpan; import io.noties.markwon.core.spans.StrongEmphasisSpan; import io.noties.markwon.editor.AbstractEditHandler; @@ -65,7 +69,8 @@ public MenuOptions menuOptions() { .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) .add("pluginRequire", this::plugin_require) .add("pluginNoDefaults", this::plugin_no_defaults) - .add("heading", this::heading); + .add("heading", this::heading) + .add("newLine", this::newLine); } @Override @@ -98,7 +103,10 @@ public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); createView(); + Debug.init(new AndroidLogDebugOutput(true)); + multiple_edit_spans(); +// newLine(); } private void simple_process() { @@ -230,6 +238,7 @@ public void configureParser(@NonNull Parser.Builder builder) { builder.inlineParserFactory(inlineParserFactory); } }) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) .build(); final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); @@ -280,6 +289,13 @@ private void multiple_edit_spans_plugin() { editor, Executors.newSingleThreadExecutor(), editText)); } + private void newLine() { + final Markwon markwon = Markwon.create(this); + final MarkwonEditor editor = MarkwonEditor.create(markwon); + final TextWatcher textWatcher = MarkdownNewLine.wrap(MarkwonEditorTextWatcher.withProcess(editor)); + editText.addTextChangedListener(textWatcher); + } + private void plugin_require() { // usage of plugin from other plugins @@ -295,6 +311,8 @@ public void configure(@NonNull Registry registry) { }) .build(); + editText.setMovementMethod(LinkMovementMethod.getInstance()); + final MarkwonEditor editor = MarkwonEditor.create(markwon); editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java b/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java new file mode 100644 index 00000000..9552f2ba --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/editor/MarkdownNewLine.java @@ -0,0 +1,129 @@ +package io.noties.markwon.sample.editor; + +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; + +import androidx.annotation.NonNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.debug.Debug; + +abstract class MarkdownNewLine { + + @NonNull + static TextWatcher wrap(@NonNull TextWatcher textWatcher) { + return new NewLineTextWatcher(textWatcher); + } + + private MarkdownNewLine() { + } + + private static class NewLineTextWatcher implements TextWatcher { + + // NB! matches only bullet lists + private final Pattern RE = Pattern.compile("^( {0,3}[\\-+* ]+)(.+)*$"); + + private final TextWatcher wrapped; + + private boolean selfChange; + + // this content is pending to be inserted at the beginning + private String pendingNewLineContent; + private int pendingNewLineIndex; + + // mark current edited line for removal (range start/end) + private int clearLineStart; + private int clearLineEnd; + + NewLineTextWatcher(@NonNull TextWatcher wrapped) { + this.wrapped = wrapped; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // no op + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (selfChange) { + return; + } + + // just one new character added + if (before == 0 + && count == 1 + && '\n' == s.charAt(start)) { + int end = -1; + for (int i = start - 1; i >= 0; i--) { + if ('\n' == s.charAt(i)) { + end = i + 1; + break; + } + } + + // start at the very beginning + if (end < 0) { + end = 0; + } + + final String pendingNewLineContent; + + final int clearLineStart; + final int clearLineEnd; + + final Matcher matcher = RE.matcher(s.subSequence(end, start)); + if (matcher.matches()) { + // if second group is empty -> remove new line + final String content = matcher.group(2); + Debug.e("new line, content: '%s'", content); + if (TextUtils.isEmpty(content)) { + // another empty new line, remove this start + clearLineStart = end; + clearLineEnd = start; + pendingNewLineContent = null; + } else { + pendingNewLineContent = matcher.group(1); + clearLineStart = clearLineEnd = 0; + } + } else { + pendingNewLineContent = null; + clearLineStart = clearLineEnd = 0; + } + this.pendingNewLineContent = pendingNewLineContent; + this.pendingNewLineIndex = start + 1; + this.clearLineStart = clearLineStart; + this.clearLineEnd = clearLineEnd; + } + } + + @Override + public void afterTextChanged(Editable s) { + if (selfChange) { + return; + } + + if (pendingNewLineContent != null || clearLineStart < clearLineEnd) { + selfChange = true; + try { + if (pendingNewLineContent != null) { + s.insert(pendingNewLineIndex, pendingNewLineContent); + pendingNewLineContent = null; + } else { + s.replace(clearLineStart, clearLineEnd, ""); + clearLineStart = clearLineEnd = 0; + } + } finally { + selfChange = false; + } + } + + // NB, we assume MarkdownEditor text watcher that only listens for this event, + // other text-watchers must be interested in other events also + wrapped.afterTextChanged(s); + } + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java b/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java new file mode 100644 index 00000000..307ea834 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/ElegantUnderlineSpan.java @@ -0,0 +1,242 @@ +package io.noties.markwon.sample.html; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.os.Build; +import android.text.Layout; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.LineBackgroundSpan; +import android.text.style.MetricAffectingSpan; +import android.util.Log; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Px; +import androidx.annotation.RequiresApi; + +import io.noties.markwon.core.spans.TextLayoutSpan; +import io.noties.markwon.core.spans.TextViewSpan; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +/** + * Credit goes to [Romain Guy](https://github.com/romainguy/elegant-underline) + *

+ * Failed attempt to create elegant underline as a span + *

    + *
  • in a `TextView` span is rendered, but `draw` method is invoked constantly which put pressure on CPU and memory + *
  • in an `EditText` only the first line draws this underline span (seems to be a weird + * issue between LineBackgroundSpan and EditText). Also, in `EditText` `draw` method is invoked + * constantly (for each drawing of the blinking cursor) + *
  • cannot reliably receive proper text, for example if underline is applied to a text range which has + * different typefaces applied to different words (underline cannot know that, which applied to which) + *
+ */ +// will apply other spans that 100% contain this one, so for example if +// an underline that inside some other spans (different typeface), they won't be applied and thus +// underline would be incorrect +// do not use in editor, due to some obscure thing, LineBackgroundSpan would be applied to the first line only +// also, in editor this span would be redrawn with each blink of the cursor +@RequiresApi(Build.VERSION_CODES.KITKAT) +class ElegantUnderlineSpan implements LineBackgroundSpan { + + private static final float DEFAULT_UNDERLINE_HEIGHT_DIP = 0.8F; + private static final float DEFAULT_UNDERLINE_CLEAR_GAP_DIP = 5.5F; + + @NonNull + public static ElegantUnderlineSpan create() { + return new ElegantUnderlineSpan(0, 0); + } + + @NonNull + public static ElegantUnderlineSpan create(@Px int underlineHeight) { + return new ElegantUnderlineSpan(underlineHeight, 0); + } + + @NonNull + public static ElegantUnderlineSpan create(@Px int underlineHeight, @Px int underlineClearGap) { + return new ElegantUnderlineSpan(underlineHeight, underlineClearGap); + } + + // TODO: underline color? + private final int underlineHeight; + private final int underlineClearGap; + + private final Path underline = new Path(); + private final Path outline = new Path(); + private final Paint stroke = new Paint(); + private final Path strokedOutline = new Path(); + + private final CharCache charCache = new CharCache(); + + private final TextPaint tempTextPaint = new TextPaint(); + + protected ElegantUnderlineSpan(@Px int underlineHeight, @Px int underlineClearGap) { + this.underlineHeight = underlineHeight; + this.underlineClearGap = underlineClearGap; + stroke.setStyle(Paint.Style.FILL_AND_STROKE); + stroke.setStrokeCap(Paint.Cap.BUTT); + } + + // is it possible that LineBackgroundSpan is not receiving proper spans? like typeface? + // it complicates things (like the need to have own copy of paint) + + // is it possible that LineBackgroundSpan is called constantly even in a TextView? + + @Override + public void drawBackground( + Canvas c, + Paint p, + int left, + int right, + int top, + int baseline, + int bottom, + CharSequence text, + int start, + int end, + int lnum + ) { + +// Debug.trace(); + + final Spanned spanned = (Spanned) text; + final TextView textView = TextViewSpan.textViewOf(spanned); + + if (textView == null) { + // TextView is required + Log.e("EU", "no text view"); + return; + } + + final Layout layout; + { + // check if there is dedicated layout, if not, use from textView + // (think tableRowSpan that uses own Layout) + final Layout layoutFromSpan = TextLayoutSpan.layoutOf(spanned); + if (layoutFromSpan != null) { + layout = layoutFromSpan; + } else { + layout = textView.getLayout(); + } + } + + if (layout == null) { + // we could call `p.setUnderlineText(true)` here a fallback, + // but this would make __all__ text in a TextView underlined, which is not + // what we want + Log.e("EU", "no layout"); + return; + } + + tempTextPaint.set((TextPaint) p); + + // we must use _selfStart_ because underline can start **not** at the beginning of a line. + // as we are using LineBackground `start` would indicate the start position of the line + // and not start of the span (self). The same goes for selfEnd (ended before line) + final int selfStart = spanned.getSpanStart(this); + final int selfEnd = spanned.getSpanEnd(this); + + final int s = max(selfStart, start); + + // all lines should use (end - 1) to receive proper line end coordinate X, + // unless it is last line in _layout_ + final boolean isLastLine = lnum == (layout.getLineCount() - 1); + final int e = min(selfEnd, end - (isLastLine ? 0 : 1)); + + if (true) { + Log.e("EU", String.format("lnum: %s, hash: %s, text: '%s'", + lnum, text.subSequence(s, e).hashCode(), text.subSequence(s, e))); + } + + final int leading; + final int trailing; + { + final int l = (int) (layout.getPrimaryHorizontal(s) + .5F); + final int r = (int) (layout.getPrimaryHorizontal(e) + .5F); + leading = min(l, r); + trailing = max(l, r); + } + + underline.rewind(); + + // middle between baseline and descent + final int diff = (int) (p.descent() / 2F + .5F); + + underline.addRect( + leading, baseline + diff, + trailing, baseline + diff + underlineHeight(textView), + Path.Direction.CW + ); + + outline.rewind(); + + final int charsLength = e - s; + final char[] chars = charCache.chars(charsLength); + TextUtils.getChars(spanned, s, e, chars, 0); + + if (true) { + final MetricAffectingSpan[] metricAffectingSpans = spanned.getSpans(s, e, MetricAffectingSpan.class); +// Log.e("EU", Arrays.toString(metricAffectingSpans)); + for (MetricAffectingSpan span : metricAffectingSpans) { + span.updateMeasureState(tempTextPaint); + } + } + + // todo: styleSpan + // todo all other spans (maybe UpdateMeasureSpans?) + tempTextPaint.getTextPath( + chars, + 0, charsLength, + leading, baseline, + outline + ); + + outline.op(underline, Path.Op.INTERSECT); + + strokedOutline.rewind(); + stroke.setStrokeWidth(underlineClearGap(textView)); + stroke.getFillPath(outline, strokedOutline); + + underline.op(strokedOutline, Path.Op.DIFFERENCE); + + c.drawPath(underline, p); + } + + private int underlineHeight(@NonNull TextView textView) { + if (underlineHeight > 0) { + return underlineHeight; + } + return (int) (DEFAULT_UNDERLINE_HEIGHT_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); + } + + private int underlineClearGap(@NonNull TextView textView) { + if (underlineClearGap > 0) { + return underlineClearGap; + } + return (int) (DEFAULT_UNDERLINE_CLEAR_GAP_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); + } + + // primitive cache that grows internal array (never shrinks, nor clear buffer) + // TODO: but... each span has own instance, so not much of the memory saving + private static class CharCache { + + @NonNull + char[] chars(int ofLength) { + final char[] out; + if (chars == null || chars.length < ofLength) { + out = chars = new char[ofLength]; + } else { + out = chars; + } + return out; + } + + private char[] chars; + } +} + diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java index db5ca541..1f289f79 100644 --- a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java @@ -1,18 +1,18 @@ package io.noties.markwon.sample.html; +import android.os.Build; import android.os.Bundle; import android.text.Layout; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.AlignmentSpan; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; -import org.commonmark.node.Paragraph; - import java.util.Collection; import java.util.Collections; import java.util.Random; @@ -23,6 +23,7 @@ import io.noties.markwon.MarkwonVisitor; import io.noties.markwon.RenderProps; import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.html.HtmlEmptyTagReplacement; import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.HtmlTag; import io.noties.markwon.html.MarkwonHtmlRenderer; @@ -42,7 +43,10 @@ public MenuOptions menuOptions() { .add("align", this::align) .add("randomCharSize", this::randomCharSize) .add("enhance", this::enhance) - .add("image", this::image); + .add("image", this::image) +// .add("elegantUnderline", this::elegantUnderline) + .add("iframe", this::iframe) + .add("emptyTagReplacement", this::emptyTagReplacement); } private TextView textView; @@ -56,8 +60,8 @@ public void onCreate(@Nullable Bundle savedInstanceState) { // let's define some custom tag-handlers textView = findViewById(R.id.text_view); - - align(); + + emptyTagReplacement(); } // we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content @@ -268,4 +272,86 @@ private void image() { markwon.setMarkdown(textView, md); } + + private void elegantUnderline() { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + Toast.makeText( + this, + "Elegant underline is supported on KitKat and up", + Toast.LENGTH_LONG + ).show(); + return; + } + + final String underline = "Well well wel, and now Gogogo, quite **perfect** yeah and nice and elegant"; + + final String md = "" + + underline + "\n\n" + + "" + underline + "\n\n" + + "" + underline + "\n\n" + + "" + underline + underline + underline + "\n\n" + + "" + underline + "\n\n" + + ""; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(HtmlPlugin.create(plugin -> plugin + .addHandler(new HtmlFontTagHandler()) + .addHandler(new HtmlElegantUnderlineTagHandler()))) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void iframe() { + final String md = "" + + "# Hello iframe\n\n" + + "

\"JUMP

\n" + + "

 

\n" + + "

Switch owners will soon get to take part in the ultimate Shonen Jump rumble. Bandai Namco announced plans to bring Jump Force to Switch as Jump Force Deluxe Edition, with a release set for sometime this year. This version will include all of the original playable characters and those from Character Pass 1, and Character Pass 2 is also in the works for all versions, starting with Shoto Todoroki from My Hero Academia.

\n" + + "

 

\n" + + "

Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from Hunter x Hunter, Yu Yu Hakusho, Bleach, and JoJo's Bizarre Adventure. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring. 

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

Character Pass 2 promo:

\n" + + "

 

\n" + + "

\n" + + "

 

\n" + + "

\"\"

\n" + + "

 

\n" + + "

-------

\n" + + "

Joseph Luster is the Games and Web editor at Otaku USA Magazine. You can read his webcomic, BIG DUMB FIGHTING IDIOTS at subhumanzoids. Follow him on Twitter @Moldilox. 

"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(ImagesPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new IFrameHtmlPlugin()) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void emptyTagReplacement() { + + final String md = "" + + " the `` is replaced?"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(HtmlPlugin.create(plugin -> { + plugin.emptyTagReplacement(new HtmlEmptyTagReplacement() { + @Nullable + @Override + public String replace(@NonNull HtmlTag tag) { + if ("empty".equals(tag.name())) { + return "REPLACED_EMPTY_WITH_IT"; + } + return super.replace(tag); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } } diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java new file mode 100644 index 00000000..76a2e5fb --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlElegantUnderlineTagHandler.java @@ -0,0 +1,38 @@ +package io.noties.markwon.sample.html; + +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; + +@RequiresApi(Build.VERSION_CODES.KITKAT) +public class HtmlElegantUnderlineTagHandler extends TagHandler { + + @Override + public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { + if (tag.isBlock()) { + visitChildren(visitor, renderer, tag.getAsBlock()); + } + SpannableBuilder.setSpans( + visitor.builder(), + ElegantUnderlineSpan.create(), + tag.start(), + tag.end() + ); + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("u"); + } +} \ No newline at end of file diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java new file mode 100644 index 00000000..477cbd59 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlFontTagHandler.java @@ -0,0 +1,42 @@ +package io.noties.markwon.sample.html; + +import android.text.TextUtils; +import android.text.style.TypefaceSpan; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; + +public class HtmlFontTagHandler extends TagHandler { + + @Override + public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { + + if (tag.isBlock()) { + visitChildren(visitor, renderer, tag.getAsBlock()); + } + + final String font = tag.attributes().get("name"); + if (!TextUtils.isEmpty(font)) { + SpannableBuilder.setSpans( + visitor.builder(), + new TypefaceSpan(font), + tag.start(), + tag.end() + ); + } + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("font"); + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java b/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java new file mode 100644 index 00000000..ddbbfce7 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/html/IFrameHtmlPlugin.java @@ -0,0 +1,48 @@ +package io.noties.markwon.sample.html; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.Image; + +import java.util.Collection; +import java.util.Collections; + +import io.noties.debug.Debug; +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.RenderProps; +import io.noties.markwon.html.HtmlPlugin; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.tag.SimpleTagHandler; +import io.noties.markwon.image.ImageProps; +import io.noties.markwon.image.ImageSize; + +public class IFrameHtmlPlugin extends AbstractMarkwonPlugin { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> { + // TODO: empty tag replacement + htmlPlugin.addHandler(new EmbedTagHandler()); + }); + } + + private static class EmbedTagHandler extends SimpleTagHandler { + + @Nullable + @Override + public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { + final ImageSize imageSize = new ImageSize(new ImageSize.Dimension(640, "px"), new ImageSize.Dimension(480, "px")); + ImageProps.IMAGE_SIZE.set(renderProps, imageSize); + ImageProps.DESTINATION.set(renderProps, "https://hey.com/1.png"); + return configuration.spansFactory().require(Image.class) + .getSpans(configuration, renderProps); + } + + @NonNull + @Override + public Collection supportedTags() { + return Collections.singleton("iframe"); + } + } +} 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 index 4e7c87da..718d40e7 100644 --- a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java @@ -6,10 +6,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.commonmark.internal.inline.AsteriskDelimiterProcessor; +import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; import org.commonmark.node.Block; import org.commonmark.node.BlockQuote; +import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.Heading; import org.commonmark.node.HtmlBlock; +import org.commonmark.node.IndentedCodeBlock; import org.commonmark.node.ListBlock; import org.commonmark.node.ThematicBreak; import org.commonmark.parser.InlineParserFactory; @@ -22,7 +26,9 @@ import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.Markwon; import io.noties.markwon.inlineparser.BackticksInlineProcessor; +import io.noties.markwon.inlineparser.BangInlineProcessor; import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; +import io.noties.markwon.inlineparser.HtmlInlineProcessor; import io.noties.markwon.inlineparser.MarkwonInlineParser; import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; @@ -41,7 +47,9 @@ public MenuOptions menuOptions() { .add("links_only", this::links_only) .add("disable_code", this::disable_code) .add("pluginWithDefaults", this::pluginWithDefaults) - .add("pluginNoDefaults", this::pluginNoDefaults); + .add("pluginNoDefaults", this::pluginNoDefaults) + .add("disableHtmlInlineParser", this::disableHtmlInlineParser) + .add("disableHtmlSanitize", this::disableHtmlSanitize); } @Override @@ -173,4 +181,67 @@ public void configure(@NonNull Registry registry) { markwon.setMarkdown(textView, md); } + private void disableHtmlInlineParser() { + final String md = "# Html disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + // NB! `AsteriskDelimiterProcessor` and `UnderscoreDelimiterProcessor` + // handles both emphasis and strong-emphasis nodes + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .excludeInlineProcessor(HtmlInlineProcessor.class) + .excludeInlineProcessor(BangInlineProcessor.class) + .excludeInlineProcessor(OpenBracketInlineProcessor.class) + .excludeDelimiterProcessor(AsteriskDelimiterProcessor.class) + .excludeDelimiterProcessor(UnderscoreDelimiterProcessor.class); + }); + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.enabledBlockTypes(new HashSet<>(Arrays.asList( + Heading.class, +// HtmlBlock.class, + ThematicBreak.class, + FencedCodeBlock.class, + IndentedCodeBlock.class, + BlockQuote.class, + ListBlock.class + ))); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void disableHtmlSanitize() { + final String md = "# Html disabled\n\n" + + "emphasis strong\n\n" + + "

paragraph

\n\n" + + "\n\n" + + ""; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @NonNull + @Override + public String processMarkdown(@NonNull String markdown) { + return markdown + .replaceAll("<", "<") + .replaceAll(">", ">"); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } } diff --git a/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java b/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java index b8a7d9e3..d2cd69ac 100644 --- a/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java @@ -68,7 +68,8 @@ public MenuOptions menuOptions() { .add("textColor", this::textColor) .add("defaultTextColor", this::defaultTextColor) .add("inlineAndBlock", this::inlineAndBlock) - .add("dark", this::dark); + .add("dark", this::dark) + .add("omega", this::omega); } @Override @@ -221,6 +222,18 @@ private void dark() { renderWithBlocksAndInlines(md); } + private void omega() { + final String md = "" + + "# Block\n\n" + + "$$\n" + + "\\Omega\n" + + "$$\n\n" + + "# Inline\n\n" + + "$$\\Omega$$"; + + renderWithBlocksAndInlines(md); + } + @NonNull private static String wrapLatexInSampleMarkdown(@NonNull String latex) { return "" + 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 84034ce9..50bcd696 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 @@ -34,6 +34,8 @@ import io.noties.markwon.core.CorePlugin; import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.destination.ImageDestinationProcessor; +import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; import io.noties.markwon.image.file.FileSchemeHandler; import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; import io.noties.markwon.image.svg.SvgMediaDecoder; @@ -42,8 +44,6 @@ import io.noties.markwon.recycler.table.TableEntry; import io.noties.markwon.recycler.table.TableEntryPlugin; import io.noties.markwon.sample.R; -import io.noties.markwon.urlprocessor.UrlProcessor; -import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; public class RecyclerActivity extends Activity { @@ -100,7 +100,7 @@ private static Markwon markwon(@NonNull Context context) { .usePlugin(new AbstractMarkwonPlugin() { @Override public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { - builder.urlProcessor(new UrlProcessorInitialReadme()); + builder.imageDestinationProcessor(new ImageDestinationProcessorInitialReadme()); } @Override @@ -182,12 +182,12 @@ private static String readStream(@Nullable InputStream inputStream) { return out; } - private static class UrlProcessorInitialReadme implements UrlProcessor { + private static class ImageDestinationProcessorInitialReadme extends ImageDestinationProcessor { private static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/"; - private final UrlProcessorRelativeToAbsolute processor - = new UrlProcessorRelativeToAbsolute(GITHUB_BASE); + private final ImageDestinationProcessorRelativeToAbsolute processor + = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE); @NonNull @Override diff --git a/sample/src/main/java/io/noties/markwon/sample/table/TableActivity.java b/sample/src/main/java/io/noties/markwon/sample/table/TableActivity.java index d7153ec6..3dade55c 100644 --- a/sample/src/main/java/io/noties/markwon/sample/table/TableActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/table/TableActivity.java @@ -7,10 +7,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import io.noties.debug.Debug; import io.noties.markwon.Markwon; +import io.noties.markwon.ext.latex.JLatexMathPlugin; import io.noties.markwon.ext.tables.TablePlugin; -import io.noties.markwon.ext.tables.TableTheme; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.linkify.LinkifyPlugin; import io.noties.markwon.sample.ActivityWithMenuOptions; import io.noties.markwon.sample.MenuOptions; @@ -25,7 +26,9 @@ public class TableActivity extends ActivityWithMenuOptions { public MenuOptions menuOptions() { return MenuOptions.create() .add("customize", this::customize) - .add("tableAndLinkify", this::tableAndLinkify); + .add("tableAndLinkify", this::tableAndLinkify) + .add("withImages", this::withImages) + .add("withLatex", this::withLatex); } private TextView textView; @@ -86,4 +89,47 @@ private void tableAndLinkify() { markwon.setMarkdown(textView, md); } + + private void withImages() { + + final String md = "" + + "| HEADER | HEADER |\n" + + "|:----:|:----:|\n" + + "| ![Build](https://github.com/noties/Markwon/workflows/Build/badge.svg) | Build |\n" + + "| Stable | ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) |\n" + + "| BIG | ![image](https://images.pexels.com/photos/41171/brussels-sprouts-sprouts-cabbage-grocery-41171.jpeg) |\n" + + "\n"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(ImagesPlugin.create()) + .usePlugin(TablePlugin.create(this)) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void withLatex() { + + String latex = "\\begin{array}{cc}"; + latex += "\\fbox{\\text{A framed box with \\textdbend}}&\\shadowbox{\\text{A shadowed box}}\\cr"; + latex += "\\doublebox{\\text{A double framed box}}&\\ovalbox{\\text{An oval framed box}}\\cr"; + latex += "\\end{array}"; + + final String md = "" + + "| HEADER | HEADER |\n" + + "|:----:|:----:|\n" + + "| ![Build](https://github.com/noties/Markwon/workflows/Build/badge.svg) | Build |\n" + + "| Stable | ![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) |\n" + + "| BIG | $$" + latex + "$$ |\n" + + "\n"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(ImagesPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> builder.inlinesEnabled(true))) + .usePlugin(TablePlugin.create(this)) + .build(); + + markwon.setMarkdown(textView, md); + } } diff --git a/sample/src/main/res/layout/activity_text_view.xml b/sample/src/main/res/layout/activity_text_view.xml index e557a4bc..dcbe4d67 100644 --- a/sample/src/main/res/layout/activity_text_view.xml +++ b/sample/src/main/res/layout/activity_text_view.xml @@ -14,6 +14,7 @@ android:textAppearance="?android:attr/textAppearanceMedium" android:textColor="#000" android:textSize="16sp" + android:padding="8dip" tools:text="whatever" /> \ No newline at end of file