Merge pull request #248 from noties/v4.4.0

V4.4.0
This commit is contained in:
Dimitry 2020-05-19 10:17:46 +03:00 committed by GitHub
commit 710bcbcc1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1757 additions and 211 deletions

View File

@ -1,5 +1,24 @@
# Changelog # 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])<br>Thanks to [@tylerbwong]
* Rename `UrlProcessor` to `ImageDestinationProcessor` (`io.noties.markwon.urlprocessor` -&gt; `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 # 4.3.1
* Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone] * Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone]
* module `images`: `GifSupport` and `SvgSupport` use `Class.forName` instead access to full qualified class name * module `images`: `GifSupport` and `SvgSupport` use `Class.forName` instead access to full qualified class name

View File

@ -5,15 +5,15 @@ import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import io.noties.markwon.urlprocessor.UrlProcessor; import io.noties.markwon.image.destination.ImageDestinationProcessor;
import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; 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 static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/";
private final UrlProcessorRelativeToAbsolute processor private final ImageDestinationProcessorRelativeToAbsolute processor
= new UrlProcessorRelativeToAbsolute(GITHUB_BASE); = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE);
@NonNull @NonNull
@Override @Override

View File

@ -24,6 +24,8 @@ import io.noties.markwon.ext.tables.TablePlugin;
import io.noties.markwon.ext.tasklist.TaskListPlugin; import io.noties.markwon.ext.tasklist.TaskListPlugin;
import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.HtmlPlugin;
import io.noties.markwon.image.ImagesPlugin; 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.file.FileSchemeHandler;
import io.noties.markwon.image.gif.GifMediaDecoder; import io.noties.markwon.image.gif.GifMediaDecoder;
import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler;
@ -31,8 +33,6 @@ import io.noties.markwon.syntax.Prism4jTheme;
import io.noties.markwon.syntax.Prism4jThemeDarkula; import io.noties.markwon.syntax.Prism4jThemeDarkula;
import io.noties.markwon.syntax.Prism4jThemeDefault; import io.noties.markwon.syntax.Prism4jThemeDefault;
import io.noties.markwon.syntax.SyntaxHighlightPlugin; import io.noties.markwon.syntax.SyntaxHighlightPlugin;
import io.noties.markwon.urlprocessor.UrlProcessor;
import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute;
import io.noties.prism4j.Prism4j; import io.noties.prism4j.Prism4j;
@ActivityScope @ActivityScope
@ -86,11 +86,11 @@ public class MarkdownRenderer {
} }
private void execute() { private void execute() {
final UrlProcessor urlProcessor; final ImageDestinationProcessor imageDestinationProcessor;
if (uri == null) { if (uri == null) {
urlProcessor = new UrlProcessorInitialReadme(); imageDestinationProcessor = new ImageDestinationProcessorInitialReadme();
} else { } else {
urlProcessor = new UrlProcessorRelativeToAbsolute(uri.toString()); imageDestinationProcessor = new ImageDestinationProcessorRelativeToAbsolute(uri.toString());
} }
final Prism4jTheme prism4jTheme = isLightTheme final Prism4jTheme prism4jTheme = isLightTheme
@ -119,7 +119,7 @@ public class MarkdownRenderer {
.usePlugin(new AbstractMarkwonPlugin() { .usePlugin(new AbstractMarkwonPlugin() {
@Override @Override
public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) {
builder.urlProcessor(urlProcessor); builder.imageDestinationProcessor(imageDestinationProcessor);
} }
}) })
.build(); .build();

View File

@ -72,7 +72,7 @@ ext {
'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion", 'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion",
'android-svg' : 'com.caverock:androidsvg:1.4', 'android-svg' : 'com.caverock:androidsvg:1.4',
'android-gif' : 'pl.droidsonroids.gif:android-gif-drawable:1.2.15', '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', 'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0',
'prism4j' : 'io.noties:prism4j:2.0.0', 'prism4j' : 'io.noties:prism4j:2.0.0',
'debug' : 'io.noties:debug:5.0.0@jar', 'debug' : 'io.noties:debug:5.0.0@jar',
@ -80,7 +80,7 @@ ext {
'dagger' : "com.google.dagger:dagger:$daggerVersion", 'dagger' : "com.google.dagger:dagger:$daggerVersion",
'picasso' : 'com.squareup.picasso:picasso:2.71828', 'picasso' : 'com.squareup.picasso:picasso:2.71828',
'glide' : 'com.github.bumptech.glide:glide:4.9.0', 'glide' : 'com.github.bumptech.glide:glide:4.9.0',
'coil' : 'io.coil-kt:coil:0.8.0' 'coil' : 'io.coil-kt:coil:0.10.1'
] ]
deps['annotationProcessor'] = [ deps['annotationProcessor'] = [

View File

@ -99,12 +99,7 @@ and 2 themes included: Light &amp; Dark. It can be downloaded from [releases](ht
## # Awesome Markwon ## # Awesome Markwon
</div> </div>
<br>
<u>Applications using Markwon</u>:
* [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.
<AwesomeGroup :apps="[ <AwesomeGroup :apps="[
{name: 'Nextcloud', image: $withBase(`/assets/apps/nextcloud.png`), link: 'https://github.com/nextcloud/android', description: 'A safe home for all your data. Access & share your files, calendars, contacts, mail & more from any device, on your terms.'}, {name: 'Nextcloud', image: $withBase(`/assets/apps/nextcloud.png`), link: 'https://github.com/nextcloud/android', description: 'A safe home for all your data. Access & share your files, calendars, contacts, mail & more from any device, on your terms.'},
@ -113,6 +108,11 @@ and 2 themes included: Light &amp; Dark. It can be downloaded from [releases](ht
{name: 'Pure Writer', image: $withBase(`/assets/apps/purewriter.png`), link: 'https://play.google.com/store/apps/details?id=com.drakeet.purewriter', description: 'Never lose content editor & Markdown'} {name: 'Pure Writer', image: $withBase(`/assets/apps/purewriter.png`), link: 'https://play.google.com/store/apps/details?id=com.drakeet.purewriter', description: 'Never lose content editor & Markdown'}
]" /> ]" />
* [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.
<u>Extension/plugins</u>: <u>Extension/plugins</u>:

View File

@ -5,7 +5,7 @@ These are _configurable_ properties:
* `AsyncDrawableLoader` (back here since <Badge text="4.0.0" />) * `AsyncDrawableLoader` (back here since <Badge text="4.0.0" />)
* `SyntaxHighlight` * `SyntaxHighlight`
* `LinkResolver` (since <Badge text="4.0.0" />, before &mdash; `LinkSpan.Resolver`) * `LinkResolver` (since <Badge text="4.0.0" />, before &mdash; `LinkSpan.Resolver`)
* `UrlProcessor` * `ImageDestinationProcessor` (since <Badge text="4.4.0" />, before &mdash; `UrlProcessor`)
* `ImageSizeResolver` * `ImageSizeResolver`
:::tip :::tip
@ -36,10 +36,11 @@ final Markwon markwon = Markwon.builder(context)
.build(); .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 * [markwon implementation](/docs/v4/image/) with SVG, GIF, data uri and android_assets support
* [based on Picasso](/docs/v4/image-picasso/) * [based on Picasso](/docs/v4/image-picasso/)
* [based on Glide](/docs/v4/image-glide/) * [based on Glide](/docs/v4/image-glide/)
* [base on Coil](/docs/v4/image-coil/)
## SyntaxHighlight ## 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) 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). default **no-op** implementation will be used, which does not modify URLs (keeping them as-is).
`Markwon` provides 2 implementations of `UrlProcessor`: `Markwon` provides 2 implementations of `UrlProcessor`:
* `UrlProcessorRelativeToAbsolute` * `ImageDestinationProcessorRelativeToAbsolute`
* `UrlProcessorAndroidAssets` * `ImageDestinationProcessorAssets`
### UrlProcessorRelativeToAbsolute ### ImageDestinationProcessorRelativeToAbsolute
`UrlProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is `ImageDestinationProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is
defined like this: `![img](./art/image.JPG)` and `UrlProcessorRelativeToAbsolute` defined like this: `![img](./art/image.JPG)` and `ImageDestinationProcessorRelativeToAbsolute`
is created with `https://github.com/noties/Markwon/raw/master/` as the base: 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` then final image will have `https://github.com/noties/Markwon/raw/master/art/image.JPG`
as the destination. 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 So an image: `![img](./art/image.JPG)` will have `file:///android_asset/art/image.JPG` as the
destination. destination.
:::tip :::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` so a `./art/image.png` will become `file:///android_asset/art/image.JPG` whilst `https://so.me/where.png`
will be kept as-is. will be kept as-is.
::: :::

View File

@ -8,7 +8,7 @@ android.enableJetifier=true
android.enableBuildCache=true android.enableBuildCache=true
android.buildCacheDir=build/pre-dex-cache android.buildCacheDir=build/pre-dex-cache
VERSION_NAME=4.3.1 VERSION_NAME=4.4.0
GROUP=io.noties.markwon GROUP=io.noties.markwon
POM_DESCRIPTION=Markwon markdown for Android POM_DESCRIPTION=Markwon markdown for Android

View File

@ -192,6 +192,17 @@ public abstract class Markwon {
@NonNull @NonNull
Builder usePlugins(@NonNull Iterable<? extends MarkwonPlugin> plugins); Builder usePlugins(@NonNull Iterable<? extends MarkwonPlugin> 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.
* <p>
* Since 4.4.0 {@code true} by default, versions prior - {@code false}
*
* @since 4.4.0
*/
@NonNull
Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty);
@NonNull @NonNull
Markwon build(); Markwon build();
} }

View File

@ -27,6 +27,9 @@ class MarkwonBuilderImpl implements Markwon.Builder {
private Markwon.TextSetter textSetter; private Markwon.TextSetter textSetter;
// @since 4.4.0
private boolean fallbackToRawInputWhenEmpty = true;
MarkwonBuilderImpl(@NonNull Context context) { MarkwonBuilderImpl(@NonNull Context context) {
this.context = context; this.context = context;
} }
@ -71,6 +74,13 @@ class MarkwonBuilderImpl implements Markwon.Builder {
return this; return this;
} }
@NonNull
@Override
public Markwon.Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty) {
this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty;
return this;
}
@NonNull @NonNull
@Override @Override
public Markwon build() { public Markwon build() {
@ -114,7 +124,8 @@ class MarkwonBuilderImpl implements Markwon.Builder {
parserBuilder.build(), parserBuilder.build(),
visitorFactory, visitorFactory,
configuration, configuration,
Collections.unmodifiableList(plugins) Collections.unmodifiableList(plugins),
fallbackToRawInputWhenEmpty
); );
} }

View File

@ -6,15 +6,13 @@ import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.image.AsyncDrawableLoader; import io.noties.markwon.image.AsyncDrawableLoader;
import io.noties.markwon.image.ImageSizeResolver; import io.noties.markwon.image.ImageSizeResolver;
import io.noties.markwon.image.ImageSizeResolverDef; import io.noties.markwon.image.ImageSizeResolverDef;
import io.noties.markwon.image.destination.ImageDestinationProcessor;
import io.noties.markwon.syntax.SyntaxHighlight; import io.noties.markwon.syntax.SyntaxHighlight;
import io.noties.markwon.syntax.SyntaxHighlightNoOp; import io.noties.markwon.syntax.SyntaxHighlightNoOp;
import io.noties.markwon.urlprocessor.UrlProcessor;
import io.noties.markwon.urlprocessor.UrlProcessorNoOp;
/** /**
* since 3.0.0 renamed `SpannableConfiguration` -&gt; `MarkwonConfiguration` * since 3.0.0 renamed `SpannableConfiguration` -&gt; `MarkwonConfiguration`
*/ */
@SuppressWarnings("WeakerAccess")
public class MarkwonConfiguration { public class MarkwonConfiguration {
@NonNull @NonNull
@ -26,7 +24,8 @@ public class MarkwonConfiguration {
private final AsyncDrawableLoader asyncDrawableLoader; private final AsyncDrawableLoader asyncDrawableLoader;
private final SyntaxHighlight syntaxHighlight; private final SyntaxHighlight syntaxHighlight;
private final LinkResolver linkResolver; private final LinkResolver linkResolver;
private final UrlProcessor urlProcessor; // @since 4.4.0
private final ImageDestinationProcessor imageDestinationProcessor;
private final ImageSizeResolver imageSizeResolver; private final ImageSizeResolver imageSizeResolver;
// @since 3.0.0 // @since 3.0.0
@ -37,7 +36,7 @@ public class MarkwonConfiguration {
this.asyncDrawableLoader = builder.asyncDrawableLoader; this.asyncDrawableLoader = builder.asyncDrawableLoader;
this.syntaxHighlight = builder.syntaxHighlight; this.syntaxHighlight = builder.syntaxHighlight;
this.linkResolver = builder.linkResolver; this.linkResolver = builder.linkResolver;
this.urlProcessor = builder.urlProcessor; this.imageDestinationProcessor = builder.imageDestinationProcessor;
this.imageSizeResolver = builder.imageSizeResolver; this.imageSizeResolver = builder.imageSizeResolver;
this.spansFactory = builder.spansFactory; this.spansFactory = builder.spansFactory;
} }
@ -62,9 +61,12 @@ public class MarkwonConfiguration {
return linkResolver; return linkResolver;
} }
/**
* @since 4.4.0
*/
@NonNull @NonNull
public UrlProcessor urlProcessor() { public ImageDestinationProcessor imageDestinationProcessor() {
return urlProcessor; return imageDestinationProcessor;
} }
@NonNull @NonNull
@ -87,7 +89,8 @@ public class MarkwonConfiguration {
private AsyncDrawableLoader asyncDrawableLoader; private AsyncDrawableLoader asyncDrawableLoader;
private SyntaxHighlight syntaxHighlight; private SyntaxHighlight syntaxHighlight;
private LinkResolver linkResolver; private LinkResolver linkResolver;
private UrlProcessor urlProcessor; // @since 4.4.0
private ImageDestinationProcessor imageDestinationProcessor;
private ImageSizeResolver imageSizeResolver; private ImageSizeResolver imageSizeResolver;
private MarkwonSpansFactory spansFactory; private MarkwonSpansFactory spansFactory;
@ -115,9 +118,12 @@ public class MarkwonConfiguration {
return this; return this;
} }
/**
* @since 4.4.0
*/
@NonNull @NonNull
public Builder urlProcessor(@NonNull UrlProcessor urlProcessor) { public Builder imageDestinationProcessor(@NonNull ImageDestinationProcessor imageDestinationProcessor) {
this.urlProcessor = urlProcessor; this.imageDestinationProcessor = imageDestinationProcessor;
return this; return this;
} }
@ -151,8 +157,9 @@ public class MarkwonConfiguration {
linkResolver = new LinkResolverDef(); linkResolver = new LinkResolverDef();
} }
if (urlProcessor == null) { // @since 4.4.0
urlProcessor = new UrlProcessorNoOp(); if (imageDestinationProcessor == null) {
imageDestinationProcessor = ImageDestinationProcessor.noOp();
} }
if (imageSizeResolver == null) { if (imageSizeResolver == null) {

View File

@ -1,6 +1,8 @@
package io.noties.markwon; package io.noties.markwon;
import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -28,19 +30,25 @@ class MarkwonImpl extends Markwon {
@Nullable @Nullable
private final TextSetter textSetter; private final TextSetter textSetter;
// @since 4.4.0
private final boolean fallbackToRawInputWhenEmpty;
MarkwonImpl( MarkwonImpl(
@NonNull TextView.BufferType bufferType, @NonNull TextView.BufferType bufferType,
@Nullable TextSetter textSetter, @Nullable TextSetter textSetter,
@NonNull Parser parser, @NonNull Parser parser,
@NonNull MarkwonVisitorFactory visitorFactory, @NonNull MarkwonVisitorFactory visitorFactory,
@NonNull MarkwonConfiguration configuration, @NonNull MarkwonConfiguration configuration,
@NonNull List<MarkwonPlugin> plugins) { @NonNull List<MarkwonPlugin> plugins,
boolean fallbackToRawInputWhenEmpty
) {
this.bufferType = bufferType; this.bufferType = bufferType;
this.textSetter = textSetter; this.textSetter = textSetter;
this.parser = parser; this.parser = parser;
this.visitorFactory = visitorFactory; this.visitorFactory = visitorFactory;
this.configuration = configuration; this.configuration = configuration;
this.plugins = plugins; this.plugins = plugins;
this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty;
} }
@NonNull @NonNull
@ -86,7 +94,18 @@ class MarkwonImpl extends Markwon {
@NonNull @NonNull
@Override @Override
public Spanned toMarkdown(@NonNull String input) { 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 @Override

View File

@ -1,5 +1,6 @@
package io.noties.markwon.core; package io.noties.markwon.core;
import android.text.Spannable;
import android.text.Spanned; import android.text.Spanned;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.widget.TextView; import android.widget.TextView;
@ -8,6 +9,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import org.commonmark.node.Block;
import org.commonmark.node.BlockQuote; import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList; import org.commonmark.node.BulletList;
import org.commonmark.node.Code; import org.commonmark.node.Code;
@ -15,6 +17,7 @@ import org.commonmark.node.Emphasis;
import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.HardLineBreak; import org.commonmark.node.HardLineBreak;
import org.commonmark.node.Heading; import org.commonmark.node.Heading;
import org.commonmark.node.HtmlBlock;
import org.commonmark.node.Image; import org.commonmark.node.Image;
import org.commonmark.node.IndentedCodeBlock; import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Link; import org.commonmark.node.Link;
@ -29,7 +32,10 @@ import org.commonmark.node.Text;
import org.commonmark.node.ThematicBreak; import org.commonmark.node.ThematicBreak;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.MarkwonConfiguration; import io.noties.markwon.MarkwonConfiguration;
@ -48,6 +54,7 @@ import io.noties.markwon.core.factory.ListItemSpanFactory;
import io.noties.markwon.core.factory.StrongEmphasisSpanFactory; import io.noties.markwon.core.factory.StrongEmphasisSpanFactory;
import io.noties.markwon.core.factory.ThematicBreakSpanFactory; import io.noties.markwon.core.factory.ThematicBreakSpanFactory;
import io.noties.markwon.core.spans.OrderedListItemSpan; import io.noties.markwon.core.spans.OrderedListItemSpan;
import io.noties.markwon.core.spans.TextViewSpan;
import io.noties.markwon.image.ImageProps; import io.noties.markwon.image.ImageProps;
/** /**
@ -88,6 +95,23 @@ public class CorePlugin extends AbstractMarkwonPlugin {
return new CorePlugin(); return new CorePlugin();
} }
/**
* @return a set with enabled by default block types
* @since 4.4.0
*/
@NonNull
public static Set<Class<? extends Block>> enabledBlockTypes() {
return new HashSet<>(Arrays.asList(
BlockQuote.class,
Heading.class,
FencedCodeBlock.class,
HtmlBlock.class,
ThematicBreak.class,
ListBlock.class,
IndentedCodeBlock.class
));
}
// @since 4.0.0 // @since 4.0.0
private final List<OnTextAddedListener> onTextAddedListeners = new ArrayList<>(0); private final List<OnTextAddedListener> onTextAddedListeners = new ArrayList<>(0);
@ -150,6 +174,13 @@ public class CorePlugin extends AbstractMarkwonPlugin {
@Override @Override
public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) {
OrderedListItemSpan.measure(textView, 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 @Override
@ -289,7 +320,7 @@ public class CorePlugin extends AbstractMarkwonPlugin {
final boolean link = parent instanceof Link; final boolean link = parent instanceof Link;
final String destination = configuration final String destination = configuration
.urlProcessor() .imageDestinationProcessor()
.process(image.getDestination()); .process(image.getDestination());
final RenderProps props = visitor.renderProps(); final RenderProps props = visitor.renderProps();
@ -493,8 +524,7 @@ public class CorePlugin extends AbstractMarkwonPlugin {
final int length = visitor.length(); final int length = visitor.length();
visitor.visitChildren(link); visitor.visitChildren(link);
final MarkwonConfiguration configuration = visitor.configuration(); final String destination = link.getDestination();
final String destination = configuration.urlProcessor().process(link.getDestination());
CoreProps.LINK_DESTINATION.set(visitor.renderProps(), destination); CoreProps.LINK_DESTINATION.set(visitor.renderProps(), destination);

View File

@ -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<Layout> reference;
@SuppressWarnings("WeakerAccess")
TextLayoutSpan(@NonNull Layout layout) {
this.reference = new WeakReference<>(layout);
}
@Nullable
public Layout layout() {
return reference.get();
}
}

View File

@ -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<TextView> reference;
public TextViewSpan(@NonNull TextView textView) {
this.reference = new WeakReference<>(textView);
}
@Nullable
public TextView textView() {
return reference.get();
}
}

View File

@ -14,6 +14,7 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import io.noties.markwon.core.MarkwonTheme; import io.noties.markwon.core.MarkwonTheme;
import io.noties.markwon.utils.SpanUtils;
@SuppressWarnings("WeakerAccess") @SuppressWarnings("WeakerAccess")
public class AsyncDrawableSpan extends ReplacementSpan { public class AsyncDrawableSpan extends ReplacementSpan {
@ -99,7 +100,11 @@ public class AsyncDrawableSpan extends ReplacementSpan {
int bottom, int bottom,
@NonNull Paint paint) { @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; final AsyncDrawable drawable = this.drawable;

View File

@ -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;
}
}
}

View File

@ -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 <strong>without scheme</strong>
* 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).
* <p>
* {@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;
}
}

View File

@ -1,4 +1,4 @@
package io.noties.markwon.urlprocessor; package io.noties.markwon.image.destination;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -6,15 +6,30 @@ import androidx.annotation.Nullable;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; 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; private final URL base;
public UrlProcessorRelativeToAbsolute(@NonNull String base) { public ImageDestinationProcessorRelativeToAbsolute(@NonNull String base) {
this.base = obtain(base); this.base = obtain(base);
} }
public ImageDestinationProcessorRelativeToAbsolute(@NonNull URL base) {
this.base = base;
}
@NonNull @NonNull
@Override @Override
public String process(@NonNull String destination) { public String process(@NonNull String destination) {

View File

@ -1,8 +0,0 @@
package io.noties.markwon.urlprocessor;
import androidx.annotation.NonNull;
public interface UrlProcessor {
@NonNull
String process(@NonNull String destination);
}

View File

@ -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 <em>assume</em> 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;
}
}

View File

@ -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;
}
}

View File

@ -18,6 +18,7 @@ public class Dip {
private final float density; private final float density;
@SuppressWarnings("WeakerAccess")
public Dip(float density) { public Dip(float density) {
this.density = density; this.density = density;
} }

View File

@ -12,6 +12,7 @@ import java.lang.reflect.Method;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
// utility class to print parsed Nodes hierarchy // utility class to print parsed Nodes hierarchy
@SuppressWarnings({"unused", "WeakerAccess"})
public abstract class DumpNodes { public abstract class DumpNodes {
public interface NodeProcessor { public interface NodeProcessor {

View File

@ -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() {
}
}

View File

@ -4,7 +4,6 @@ import android.text.Spanned;
public abstract class LeadingMarginUtils { public abstract class LeadingMarginUtils {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean selfStart(int start, CharSequence text, Object span) { public static boolean selfStart(int start, CharSequence text, Object span) {
return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start; return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start;
} }

View File

@ -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();
}
}

View File

@ -1,6 +1,7 @@
package io.noties.markwon; package io.noties.markwon;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils;
import android.widget.TextView; import android.widget.TextView;
import org.commonmark.node.Node; import org.commonmark.node.Node;
@ -50,7 +51,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.singletonList(plugin)); Collections.singletonList(plugin),
true
);
impl.parse("whatever"); impl.parse("whatever");
@ -74,7 +77,9 @@ public class MarkwonImplTest {
parser, parser,
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Arrays.asList(first, second)); Arrays.asList(first, second),
true
);
impl.parse("zero"); impl.parse("zero");
@ -102,7 +107,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
visitorFactory, visitorFactory,
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.singletonList(plugin)); Collections.singletonList(plugin),
true
);
when(visitorFactory.create()).thenReturn(visitor); when(visitorFactory.create()).thenReturn(visitor);
when(visitor.builder()).thenReturn(builder); when(visitor.builder()).thenReturn(builder);
@ -149,7 +156,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
visitorFactory, visitorFactory,
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.<MarkwonPlugin>emptyList()); Collections.<MarkwonPlugin>emptyList(),
true
);
impl.render(mock(Node.class)); impl.render(mock(Node.class));
@ -185,7 +194,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
visitorFactory, visitorFactory,
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.singletonList(plugin)); Collections.singletonList(plugin),
true
);
final AtomicBoolean flag = new AtomicBoolean(false); final AtomicBoolean flag = new AtomicBoolean(false);
final Node node = mock(Node.class); final Node node = mock(Node.class);
@ -224,7 +235,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), mock(MarkwonVisitorFactory.class, RETURNS_MOCKS),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.singletonList(plugin)); Collections.singletonList(plugin),
true
);
final TextView textView = mock(TextView.class); final TextView textView = mock(TextView.class);
final AtomicBoolean flag = new AtomicBoolean(false); final AtomicBoolean flag = new AtomicBoolean(false);
@ -272,7 +285,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
plugins); plugins,
true
);
assertTrue("First", impl.hasPlugin(First.class)); assertTrue("First", impl.hasPlugin(First.class));
assertFalse("Second", impl.hasPlugin(Second.class)); assertFalse("Second", impl.hasPlugin(Second.class));
@ -295,7 +310,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
Collections.singletonList(plugin)); Collections.singletonList(plugin),
true
);
final TextView textView = mock(TextView.class); final TextView textView = mock(TextView.class);
final Spanned spanned = mock(Spanned.class); final Spanned spanned = mock(Spanned.class);
@ -339,7 +356,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
plugins); plugins,
true
);
// should be returned // should be returned
assertNotNull(impl.requirePlugin(MarkwonPlugin.class)); assertNotNull(impl.requirePlugin(MarkwonPlugin.class));
@ -370,7 +389,9 @@ public class MarkwonImplTest {
mock(Parser.class), mock(Parser.class),
mock(MarkwonVisitorFactory.class), mock(MarkwonVisitorFactory.class),
mock(MarkwonConfiguration.class), mock(MarkwonConfiguration.class),
plugins); plugins,
true
);
final List<? extends MarkwonPlugin> list = impl.getPlugins(); final List<? extends MarkwonPlugin> list = impl.getPlugins();
@ -385,4 +406,42 @@ public class MarkwonImplTest {
assertTrue(e.getMessage(), true); 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.<MarkwonPlugin>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.<MarkwonPlugin>emptyList(),
false
);
final Spanned spanned = impl.toMarkdown(md);
assertTrue(spanned.toString(), TextUtils.isEmpty(spanned));
}
} }

View File

@ -1,4 +1,4 @@
package io.noties.markwon.urlprocessor; package io.noties.markwon.image.destination;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
@ -6,18 +6,18 @@ import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import static io.noties.markwon.image.destination.ImageDestinationProcessorAssets.BASE;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets.BASE;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE) @Config(manifest = Config.NONE)
public class UrlProcessorAndroidAssetsTest { public class ImageDestinationProcessorAssetsTest {
private UrlProcessorAndroidAssets processor; private ImageDestinationProcessorAssets processor;
@Before @Before
public void before() { public void before() {
processor = new UrlProcessorAndroidAssets(); processor = new ImageDestinationProcessorAssets();
} }
@Test @Test

View File

@ -1,4 +1,4 @@
package io.noties.markwon.urlprocessor; package io.noties.markwon.image.destination;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -9,39 +9,39 @@ import static org.junit.Assert.*;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE) @Config(manifest = Config.NONE)
public class UrlProcessorRelativeToAbsoluteTest { public class ImageDestinationProcessorRelativeToAbsoluteTest {
@Test @Test
public void malformed_base_do_not_process() { public void malformed_base_do_not_process() {
final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("!@#$%^&*("); final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("!@#$%^&*(");
final String destination = "../hey.there.html"; final String destination = "../hey.there.html";
assertEquals(destination, processor.process(destination)); assertEquals(destination, processor.process(destination));
} }
@Test @Test
public void access_root() { 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"; final String url = "/index.html";
assertEquals("https://ro.ot/index.html", processor.process(url)); assertEquals("https://ro.ot/index.html", processor.process(url));
} }
@Test @Test
public void access_same_directory() { 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"; final String url = "./.htaccess";
assertEquals("https://ro.ot/hello/.htaccess", processor.process(url)); assertEquals("https://ro.ot/hello/.htaccess", processor.process(url));
} }
@Test @Test
public void asset_directory_up() { 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"; final String url = "../cat.JPG";
assertEquals("http://ro.ot/first/cat.JPG", processor.process(url)); assertEquals("http://ro.ot/first/cat.JPG", processor.process(url));
} }
@Test @Test
public void change_directory_inside_destination() { 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"; final String url = "../first/../second/./thi.rd";
assertEquals( assertEquals(
"http://ro.ot/second/thi.rd", "http://ro.ot/second/thi.rd",
@ -51,7 +51,7 @@ public class UrlProcessorRelativeToAbsoluteTest {
@Test @Test
public void with_query_arguments() { 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"; final String url = "../index.php?ROOT=1";
assertEquals( assertEquals(
"http://ro.ot/index.php?ROOT=1", "http://ro.ot/index.php?ROOT=1",

View File

@ -458,8 +458,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex)
.textSize(theme.blockTextSize()) .textSize(theme.blockTextSize())
.align(theme.blockHorizontalAlignment()) .align(theme.blockHorizontalAlignment());
.fitCanvas(theme.blockFitCanvas());
if (backgroundProvider != null) { if (backgroundProvider != null) {
builder.background(backgroundProvider.provide()); builder.background(backgroundProvider.provide());
@ -489,8 +488,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
final int color = theme.inlineTextColor(); final int color = theme.inlineTextColor();
final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex)
.textSize(theme.inlineTextSize()) .textSize(theme.inlineTextSize());
.fitCanvas(false);
if (backgroundProvider != null) { if (backgroundProvider != null) {
builder.background(backgroundProvider.provide()); builder.background(backgroundProvider.provide());
@ -530,7 +528,20 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin {
@NonNull @NonNull
@Override @Override
public Rect resolveImageSize(@NonNull AsyncDrawable drawable) { 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;
} }
} }
} }

View File

@ -4,7 +4,10 @@ import android.annotation.SuppressLint;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.Layout; import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned; import android.text.Spanned;
import android.text.StaticLayout; import android.text.StaticLayout;
import android.text.TextPaint; import android.text.TextPaint;
@ -20,7 +23,11 @@ import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; 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.LeadingMarginUtils;
import io.noties.markwon.utils.SpanUtils;
public class TableRowSpan extends ReplacementSpan { public class TableRowSpan extends ReplacementSpan {
@ -67,7 +74,7 @@ public class TableRowSpan extends ReplacementSpan {
private final TableTheme theme; private final TableTheme theme;
private final List<Cell> cells; private final List<Cell> cells;
private final List<StaticLayout> layouts; private final List<Layout> layouts;
private final TextPaint textPaint; private final TextPaint textPaint;
private final boolean header; private final boolean header;
private final boolean odd; private final boolean odd;
@ -108,7 +115,7 @@ public class TableRowSpan extends ReplacementSpan {
if (fm != null) { if (fm != null) {
int max = 0; int max = 0;
for (StaticLayout layout : layouts) { for (Layout layout : layouts) {
final int height = layout.getHeight(); final int height = layout.getHeight();
if (height > max) { if (height > max) {
max = height; max = height;
@ -144,8 +151,9 @@ public class TableRowSpan extends ReplacementSpan {
int bottom, int bottom,
@NonNull Paint p) { @NonNull Paint p) {
if (recreateLayouts(canvas.getWidth())) { final int spanWidth = SpanUtils.width(canvas, text);
width = canvas.getWidth(); if (recreateLayouts(spanWidth)) {
width = spanWidth;
// @since 4.3.1 it's important to cast to TextPaint in order to display links, etc // @since 4.3.1 it's important to cast to TextPaint in order to display links, etc
if (p instanceof TextPaint) { if (p instanceof TextPaint) {
// there must be a reason why this method receives Paint instead of TextPaint... // there must be a reason why this method receives Paint instead of TextPaint...
@ -236,7 +244,7 @@ public class TableRowSpan extends ReplacementSpan {
final int borderTop = isFirstTableRow ? borderWidth : 0; final int borderTop = isFirstTableRow ? borderWidth : 0;
final int borderBottom = bottom - top - borderWidth; final int borderBottom = bottom - top - borderWidth;
StaticLayout layout; Layout layout;
for (int i = 0; i < size; i++) { for (int i = 0; i < size; i++) {
layout = layouts.get(i); layout = layouts.get(i);
final int save = canvas.save(); final int save = canvas.save();
@ -293,20 +301,76 @@ public class TableRowSpan extends ReplacementSpan {
final int w = (width / columns) - padding; final int w = (width / columns) - padding;
this.layouts.clear(); this.layouts.clear();
Cell cell;
StaticLayout layout;
for (int i = 0, size = cells.size(); i < size; i++) { for (int i = 0, size = cells.size(); i < size; i++) {
cell = cells.get(i); makeLayout(i, w, cells.get(i));
layout = new StaticLayout( }
cell.text, }
textPaint,
w, private void makeLayout(final int index, final int width, @NonNull final Cell cell) {
alignment(cell.alignment),
1.F, final Runnable recreate = new Runnable() {
.0F, @Override
false public void run() {
); final Invalidator invalidator = TableRowSpan.this.invalidator;
layouts.add(layout); 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 @@ public class TableRowSpan extends ReplacementSpan {
public void invalidator(@Nullable Invalidator invalidator) { public void invalidator(@Nullable Invalidator invalidator) {
this.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) {
}
}
} }

View File

@ -21,6 +21,7 @@ public class HtmlEmptyTagReplacement {
} }
private static final String IMG_REPLACEMENT = "\uFFFC"; 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 * @return replacement for supplied startTag or null if no replacement should occur (which will
@ -44,6 +45,9 @@ public class HtmlEmptyTagReplacement {
} else { } else {
replacement = alt; replacement = alt;
} }
} else if ("iframe".equals(name)) {
// @since 4.4.0 make iframe non-empty
replacement = IFRAME_REPLACEMENT;
} else { } else {
replacement = null; replacement = null;
} }

View File

@ -53,13 +53,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F;
private final MarkwonHtmlRendererImpl.Builder builder; private final MarkwonHtmlRendererImpl.Builder builder;
private final MarkwonHtmlParser htmlParser;
private MarkwonHtmlParser htmlParser;
private MarkwonHtmlRenderer htmlRenderer; private MarkwonHtmlRenderer htmlRenderer;
// @since 4.4.0
private HtmlEmptyTagReplacement emptyTagReplacement = new HtmlEmptyTagReplacement();
@SuppressWarnings("WeakerAccess") @SuppressWarnings("WeakerAccess")
HtmlPlugin() { HtmlPlugin() {
this.builder = new MarkwonHtmlRendererImpl.Builder(); this.builder = new MarkwonHtmlRendererImpl.Builder();
this.htmlParser = MarkwonHtmlParserImpl.create();
} }
/** /**
@ -104,6 +107,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
return this; return this;
} }
/**
* @param emptyTagReplacement {@link HtmlEmptyTagReplacement}
* @since 4.4.0
*/
@NonNull
public HtmlPlugin emptyTagReplacement(@NonNull HtmlEmptyTagReplacement emptyTagReplacement) {
this.emptyTagReplacement = emptyTagReplacement;
return this;
}
@Override @Override
public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) {
@ -128,6 +141,7 @@ public class HtmlPlugin extends AbstractMarkwonPlugin {
builder.addDefaultTagHandler(new HeadingHandler()); builder.addDefaultTagHandler(new HeadingHandler());
} }
htmlParser = MarkwonHtmlParserImpl.create(emptyTagReplacement);
htmlRenderer = builder.build(); htmlRenderer = builder.build();
} }

View File

@ -62,7 +62,7 @@ public class ImageHandler extends SimpleTagHandler {
return null; return null;
} }
final String destination = configuration.urlProcessor().process(src); final String destination = configuration.imageDestinationProcessor().process(src);
final ImageSize imageSize = imageSizeParser.parse(tag.attributes()); final ImageSize imageSize = imageSizeParser.parse(tag.attributes());
// todo: replacement text is link... as we are not at block level // todo: replacement text is link... as we are not at block level

View File

@ -27,7 +27,8 @@ public class LinkHandler extends SimpleTagHandler {
CoreProps.LINK_DESTINATION.set( CoreProps.LINK_DESTINATION.set(
renderProps, renderProps,
configuration.urlProcessor().process(destination)); destination
);
return spanFactory.getSpans(configuration, renderProps); return spanFactory.getSpans(configuration, renderProps);
} }

View File

@ -15,7 +15,6 @@ import java.util.Map;
import coil.Coil; import coil.Coil;
import coil.ImageLoader; import coil.ImageLoader;
import coil.api.ImageLoaders;
import coil.request.LoadRequest; import coil.request.LoadRequest;
import coil.request.RequestDisposable; import coil.request.RequestDisposable;
import coil.target.Target; import coil.target.Target;
@ -48,7 +47,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin {
@NonNull @NonNull
@Override @Override
public LoadRequest load(@NonNull AsyncDrawable drawable) { public LoadRequest load(@NonNull AsyncDrawable drawable) {
return ImageLoaders.newLoadBuilder(Coil.loader(), context) return LoadRequest.builder(context)
.data(drawable.getDestination()) .data(drawable.getDestination())
.build(); .build();
} }
@ -57,7 +56,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin {
public void cancel(@NonNull RequestDisposable disposable) { public void cancel(@NonNull RequestDisposable disposable) {
disposable.dispose(); disposable.dispose();
} }
}, Coil.loader()); }, Coil.imageLoader(context));
} }
@NonNull @NonNull
@ -67,7 +66,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin {
@NonNull @NonNull
@Override @Override
public LoadRequest load(@NonNull AsyncDrawable drawable) { public LoadRequest load(@NonNull AsyncDrawable drawable) {
return ImageLoaders.newLoadBuilder(imageLoader, context) return LoadRequest.builder(context)
.data(drawable.getDestination()) .data(drawable.getDestination())
.build(); .build();
} }
@ -129,7 +128,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin {
LoadRequest request = coilStore.load(drawable).newBuilder() LoadRequest request = coilStore.load(drawable).newBuilder()
.target(target) .target(target)
.build(); .build();
RequestDisposable disposable = imageLoader.load(request); RequestDisposable disposable = imageLoader.execute(request);
cache.put(drawable, disposable); cache.put(drawable, disposable);
} }

View File

@ -3,11 +3,12 @@ package io.noties.markwon.image.file;
import android.content.Context; import android.content.Context;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils; import android.text.TextUtils;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@ -18,7 +19,6 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets;
import io.noties.markwon.image.ImageItem; import io.noties.markwon.image.ImageItem;
import io.noties.markwon.image.SchemeHandler; import io.noties.markwon.image.SchemeHandler;
@ -30,7 +30,7 @@ public class FileSchemeHandler extends SchemeHandler {
public static final String SCHEME = "file"; public static final String SCHEME = "file";
/** /**
* @see UrlProcessorAndroidAssets * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets
*/ */
@NonNull @NonNull
public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetManager) { public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetManager) {
@ -39,7 +39,7 @@ public class FileSchemeHandler extends SchemeHandler {
/** /**
* @see #createWithAssets(AssetManager) * @see #createWithAssets(AssetManager)
* @see UrlProcessorAndroidAssets * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets
* @since 4.0.0 * @since 4.0.0
*/ */
@NonNull @NonNull

View File

@ -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`): 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. This live template would be possible to use in both inline comment and javadoc comment.

View File

@ -24,7 +24,11 @@
<activity android:name="io.noties.markwon.sample.basicplugins.BasicPluginsActivity" /> <activity android:name="io.noties.markwon.sample.basicplugins.BasicPluginsActivity" />
<activity android:name="io.noties.markwon.sample.recycler.RecyclerActivity" /> <activity android:name="io.noties.markwon.sample.recycler.RecyclerActivity" />
<activity android:name="io.noties.markwon.sample.theme.ThemeActivity" /> <activity android:name="io.noties.markwon.sample.theme.ThemeActivity" />
<activity android:name=".html.HtmlActivity" />
<activity
android:name=".html.HtmlActivity"
android:exported="true" />
<activity android:name=".simpleext.SimpleExtActivity" /> <activity android:name=".simpleext.SimpleExtActivity" />
<activity android:name=".customextension2.CustomExtensionActivity2" /> <activity android:name=".customextension2.CustomExtensionActivity2" />
<activity android:name=".precomputed.PrecomputedActivity" /> <activity android:name=".precomputed.PrecomputedActivity" />
@ -32,6 +36,7 @@
<activity <activity
android:name=".editor.EditorActivity" android:name=".editor.EditorActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity android:name=".inlineparser.InlineParserActivity" /> <activity android:name=".inlineparser.InlineParserActivity" />

View File

@ -5,6 +5,7 @@ import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.view.View;
import android.widget.ScrollView; import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
@ -20,6 +21,7 @@ import java.util.Collections;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.BlockHandlerDef; import io.noties.markwon.BlockHandlerDef;
import io.noties.markwon.LinkResolverDef;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
import io.noties.markwon.MarkwonConfiguration; import io.noties.markwon.MarkwonConfiguration;
import io.noties.markwon.MarkwonSpansFactory; import io.noties.markwon.MarkwonSpansFactory;
@ -153,7 +155,7 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions {
* <ul> * <ul>
* <li>SyntaxHighlight</li> * <li>SyntaxHighlight</li>
* <li>LinkSpan.Resolver</li> * <li>LinkSpan.Resolver</li>
* <li>UrlProcessor</li> * <li>ImageDestinationProcessor</li>
* <li>ImageSizeResolver</li> * <li>ImageSizeResolver</li>
* </ul> * </ul>
* <p> * <p>
@ -173,12 +175,18 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions {
public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) {
// for example if specified destination has no scheme info, we will // for example if specified destination has no scheme info, we will
// _assume_ that it's network request and append HTTPS scheme // _assume_ that it's network request and append HTTPS scheme
builder.urlProcessor(destination -> { builder.linkResolver(new LinkResolverDef() {
final Uri uri = Uri.parse(destination); @Override
if (TextUtils.isEmpty(uri.getScheme())) { public void resolve(@NonNull View view, @NonNull String link) {
return "https://" + destination; 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 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); 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);
// }
} }

View File

@ -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);
}
}

View File

@ -8,8 +8,14 @@ import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.commonmark.node.Block;
import org.commonmark.node.BlockQuote;
import org.commonmark.node.Node; 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.Markwon;
import io.noties.markwon.core.CorePlugin; import io.noties.markwon.core.CorePlugin;
import io.noties.markwon.sample.ActivityWithMenuOptions; import io.noties.markwon.sample.ActivityWithMenuOptions;
@ -26,7 +32,8 @@ public class CoreActivity extends ActivityWithMenuOptions {
return MenuOptions.create() return MenuOptions.create()
.add("simple", this::simple) .add("simple", this::simple)
.add("toast", this::toast) .add("toast", this::toast)
.add("alreadyParsed", this::alreadyParsed); .add("alreadyParsed", this::alreadyParsed)
.add("enabledBlockTypes", this::enabledBlockTypes);
} }
@Override @Override
@ -132,4 +139,28 @@ public class CoreActivity extends ActivityWithMenuOptions {
// apply parsed markdown // apply parsed markdown
markwon.setParsedMarkdown(textView, spanned); markwon.setParsedMarkdown(textView, spanned);
} }
private void enabledBlockTypes() {
final String md = "" +
"# Head\n\n" +
"> and disabled quote\n\n" +
"```\n" +
"yep\n" +
"```";
final Set<Class<? extends Block>> 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);
}
} }

View File

@ -6,6 +6,7 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextPaint; import android.text.TextPaint;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.text.style.MetricAffectingSpan; import android.text.style.MetricAffectingSpan;
@ -25,8 +26,11 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import io.noties.debug.AndroidLogDebugOutput;
import io.noties.debug.Debug;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
import io.noties.markwon.SoftBreakAddsNewLinePlugin;
import io.noties.markwon.core.spans.EmphasisSpan; import io.noties.markwon.core.spans.EmphasisSpan;
import io.noties.markwon.core.spans.StrongEmphasisSpan; import io.noties.markwon.core.spans.StrongEmphasisSpan;
import io.noties.markwon.editor.AbstractEditHandler; import io.noties.markwon.editor.AbstractEditHandler;
@ -65,7 +69,8 @@ public class EditorActivity extends ActivityWithMenuOptions {
.add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin)
.add("pluginRequire", this::plugin_require) .add("pluginRequire", this::plugin_require)
.add("pluginNoDefaults", this::plugin_no_defaults) .add("pluginNoDefaults", this::plugin_no_defaults)
.add("heading", this::heading); .add("heading", this::heading)
.add("newLine", this::newLine);
} }
@Override @Override
@ -98,7 +103,10 @@ public class EditorActivity extends ActivityWithMenuOptions {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
createView(); createView();
Debug.init(new AndroidLogDebugOutput(true));
multiple_edit_spans(); multiple_edit_spans();
// newLine();
} }
private void simple_process() { private void simple_process() {
@ -230,6 +238,7 @@ public class EditorActivity extends ActivityWithMenuOptions {
builder.inlineParserFactory(inlineParserFactory); builder.inlineParserFactory(inlineParserFactory);
} }
}) })
.usePlugin(SoftBreakAddsNewLinePlugin.create())
.build(); .build();
final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link);
@ -280,6 +289,13 @@ public class EditorActivity extends ActivityWithMenuOptions {
editor, Executors.newSingleThreadExecutor(), editText)); 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() { private void plugin_require() {
// usage of plugin from other plugins // usage of plugin from other plugins
@ -295,6 +311,8 @@ public class EditorActivity extends ActivityWithMenuOptions {
}) })
.build(); .build();
editText.setMovementMethod(LinkMovementMethod.getInstance());
final MarkwonEditor editor = MarkwonEditor.create(markwon); final MarkwonEditor editor = MarkwonEditor.create(markwon);
editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender(

View File

@ -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);
}
}
}

View File

@ -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)
* <p>
* Failed attempt to create elegant underline as a span
* <ul>
* <li>in a `TextView` span is rendered, but `draw` method is invoked constantly which put pressure on CPU and memory
* <li>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)
* <li>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)
* </ul>
*/
// 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;
}
}

View File

@ -1,18 +1,18 @@
package io.noties.markwon.sample.html; package io.noties.markwon.sample.html;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.Layout; import android.text.Layout;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan; import android.text.style.AbsoluteSizeSpan;
import android.text.style.AlignmentSpan; import android.text.style.AlignmentSpan;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.Px; import androidx.annotation.Px;
import org.commonmark.node.Paragraph;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Random; import java.util.Random;
@ -23,6 +23,7 @@ import io.noties.markwon.MarkwonConfiguration;
import io.noties.markwon.MarkwonVisitor; import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.RenderProps; import io.noties.markwon.RenderProps;
import io.noties.markwon.SpannableBuilder; import io.noties.markwon.SpannableBuilder;
import io.noties.markwon.html.HtmlEmptyTagReplacement;
import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.HtmlPlugin;
import io.noties.markwon.html.HtmlTag; import io.noties.markwon.html.HtmlTag;
import io.noties.markwon.html.MarkwonHtmlRenderer; import io.noties.markwon.html.MarkwonHtmlRenderer;
@ -42,7 +43,10 @@ public class HtmlActivity extends ActivityWithMenuOptions {
.add("align", this::align) .add("align", this::align)
.add("randomCharSize", this::randomCharSize) .add("randomCharSize", this::randomCharSize)
.add("enhance", this::enhance) .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; private TextView textView;
@ -57,7 +61,7 @@ public class HtmlActivity extends ActivityWithMenuOptions {
textView = findViewById(R.id.text_view); textView = findViewById(R.id.text_view);
align(); emptyTagReplacement();
} }
// we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content // we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content
@ -268,4 +272,86 @@ public class HtmlActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); 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 <u>Gogogo, quite **perfect** yeah</u> and nice and elegant";
final String md = "" +
underline + "\n\n" +
"<b>" + underline + "</b>\n\n" +
"<font name=serif>" + underline + "</font>\n\n" +
"<font name=sans-serif>" + underline + underline + underline + "</font>\n\n" +
"<font name=monospace>" + underline + "</font>\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" +
"<p class=\"p1\"><img title=\"JUMP FORCE\" src=\"https://img1.ak.crunchyroll.com/i/spire1/f0c009039dd9f8dff5907fff148adfca1587067000_full.jpg\" alt=\"JUMP FORCE\" width=\"640\" height=\"362\" /></p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\">Switch owners will soon get to take part in the ultimate <em>Shonen Jump </em>rumble. Bandai Namco announced plans to bring <strong><em>Jump Force </em></strong>to <strong>Switch</strong> as <strong><em>Jump Force Deluxe Edition</em></strong>, 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 <strong>Character Pass 2 is also in the works </strong>for all versions, starting with <strong>Shoto Todoroki from </strong><span style=\"color: #ff9900;\"><a href=\"/my-hero-academia?utm_source=editorial_cr&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><strong><em>My Hero Academia</em></strong></span></a></span>.</p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\">Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from <span style=\"color: #ff9900;\"><a href=\"/hunter-x-hunter?utm_source=editorial_cr&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Hunter x Hunter</em></span></a></span>, <em>Yu Yu Hakusho</em>, <span style=\"color: #ff9900;\"><a href=\"/bleach?utm_source=editorial_cr&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Bleach</em></span></a></span>, and <span style=\"color: #ff9900;\"><a href=\"/jojos-bizarre-adventure?utm_source=editorial_cr&amp;utm_medium=news&amp;utm_campaign=article_driven&amp;referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>JoJo's Bizarre Adventure</em></span></a></span>. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring.<span class=\"Apple-converted-space\">&nbsp;</span></p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/At1qTj-LWCc\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\">Character Pass 2 promo:</p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/CukwN6kV4R4\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\"><a href=\"https://got.cr/PremiumTrial-NewsBanner4\"><img style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://img1.ak.crunchyroll.com/i/spire4/78f5441d927cf160a93e037b567c2b1f1587067041_full.png\" alt=\"\" width=\"640\" height=\"43\" /></a></p>\n" +
"<p class=\"p2\">&nbsp;</p>\n" +
"<p class=\"p1\">-------</p>\n" +
"<p class=\"p1\"><em>Joseph Luster is the Games and Web editor at </em><a href=\"http://www.otakuusamagazine.com/ME2/Default.asp\"><em>Otaku USA Magazine</em></a><em>. You can read his webcomic, </em><a href=\"http://subhumanzoids.com/comics/big-dumb-fighting-idiots/\">BIG DUMB FIGHTING IDIOTS</a><em> at </em><a href=\"http://subhumanzoids.com/\"><em>subhumanzoids</em></a><em>. Follow him on Twitter </em><a href=\"https://twitter.com/Moldilox\"><em>@Moldilox</em></a><em>.</em><span class=\"Apple-converted-space\">&nbsp;</span></p>";
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 = "" +
"<empty></empty> the `<empty></empty>` 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);
}
} }

View File

@ -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<String> supportedTags() {
return Collections.singleton("u");
}
}

View File

@ -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<String> supportedTags() {
return Collections.singleton("font");
}
}

View File

@ -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<String> supportedTags() {
return Collections.singleton("iframe");
}
}
}

View File

@ -6,10 +6,14 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; 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.Block;
import org.commonmark.node.BlockQuote; import org.commonmark.node.BlockQuote;
import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading; import org.commonmark.node.Heading;
import org.commonmark.node.HtmlBlock; import org.commonmark.node.HtmlBlock;
import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.ListBlock; import org.commonmark.node.ListBlock;
import org.commonmark.node.ThematicBreak; import org.commonmark.node.ThematicBreak;
import org.commonmark.parser.InlineParserFactory; import org.commonmark.parser.InlineParserFactory;
@ -22,7 +26,9 @@ import java.util.Set;
import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.AbstractMarkwonPlugin;
import io.noties.markwon.Markwon; import io.noties.markwon.Markwon;
import io.noties.markwon.inlineparser.BackticksInlineProcessor; import io.noties.markwon.inlineparser.BackticksInlineProcessor;
import io.noties.markwon.inlineparser.BangInlineProcessor;
import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; import io.noties.markwon.inlineparser.CloseBracketInlineProcessor;
import io.noties.markwon.inlineparser.HtmlInlineProcessor;
import io.noties.markwon.inlineparser.MarkwonInlineParser; import io.noties.markwon.inlineparser.MarkwonInlineParser;
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin;
import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; import io.noties.markwon.inlineparser.OpenBracketInlineProcessor;
@ -41,7 +47,9 @@ public class InlineParserActivity extends ActivityWithMenuOptions {
.add("links_only", this::links_only) .add("links_only", this::links_only)
.add("disable_code", this::disable_code) .add("disable_code", this::disable_code)
.add("pluginWithDefaults", this::pluginWithDefaults) .add("pluginWithDefaults", this::pluginWithDefaults)
.add("pluginNoDefaults", this::pluginNoDefaults); .add("pluginNoDefaults", this::pluginNoDefaults)
.add("disableHtmlInlineParser", this::disableHtmlInlineParser)
.add("disableHtmlSanitize", this::disableHtmlSanitize);
} }
@Override @Override
@ -173,4 +181,67 @@ public class InlineParserActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); markwon.setMarkdown(textView, md);
} }
private void disableHtmlInlineParser() {
final String md = "# Html <b>disabled</b>\n\n" +
"<em>emphasis <strong>strong</strong>\n\n" +
"<p>paragraph <img src='hey.jpg' /></p>\n\n" +
"<test></test>\n\n" +
"<test>";
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 <b>disabled</b>\n\n" +
"<em>emphasis <strong>strong</strong>\n\n" +
"<p>paragraph <img src='hey.jpg' /></p>\n\n" +
"<test></test>\n\n" +
"<test>";
final Markwon markwon = Markwon.builder(this)
.usePlugin(new AbstractMarkwonPlugin() {
@NonNull
@Override
public String processMarkdown(@NonNull String markdown) {
return markdown
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
})
.build();
markwon.setMarkdown(textView, md);
}
} }

View File

@ -68,7 +68,8 @@ public class LatexActivity extends ActivityWithMenuOptions {
.add("textColor", this::textColor) .add("textColor", this::textColor)
.add("defaultTextColor", this::defaultTextColor) .add("defaultTextColor", this::defaultTextColor)
.add("inlineAndBlock", this::inlineAndBlock) .add("inlineAndBlock", this::inlineAndBlock)
.add("dark", this::dark); .add("dark", this::dark)
.add("omega", this::omega);
} }
@Override @Override
@ -221,6 +222,18 @@ public class LatexActivity extends ActivityWithMenuOptions {
renderWithBlocksAndInlines(md); renderWithBlocksAndInlines(md);
} }
private void omega() {
final String md = "" +
"# Block\n\n" +
"$$\n" +
"\\Omega\n" +
"$$\n\n" +
"# Inline\n\n" +
"$$\\Omega$$";
renderWithBlocksAndInlines(md);
}
@NonNull @NonNull
private static String wrapLatexInSampleMarkdown(@NonNull String latex) { private static String wrapLatexInSampleMarkdown(@NonNull String latex) {
return "" + return "" +

View File

@ -34,6 +34,8 @@ import io.noties.markwon.MarkwonVisitor;
import io.noties.markwon.core.CorePlugin; import io.noties.markwon.core.CorePlugin;
import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.HtmlPlugin;
import io.noties.markwon.image.ImagesPlugin; 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.file.FileSchemeHandler;
import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler;
import io.noties.markwon.image.svg.SvgMediaDecoder; import io.noties.markwon.image.svg.SvgMediaDecoder;
@ -42,8 +44,6 @@ import io.noties.markwon.recycler.SimpleEntry;
import io.noties.markwon.recycler.table.TableEntry; import io.noties.markwon.recycler.table.TableEntry;
import io.noties.markwon.recycler.table.TableEntryPlugin; import io.noties.markwon.recycler.table.TableEntryPlugin;
import io.noties.markwon.sample.R; import io.noties.markwon.sample.R;
import io.noties.markwon.urlprocessor.UrlProcessor;
import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute;
public class RecyclerActivity extends Activity { public class RecyclerActivity extends Activity {
@ -100,7 +100,7 @@ public class RecyclerActivity extends Activity {
.usePlugin(new AbstractMarkwonPlugin() { .usePlugin(new AbstractMarkwonPlugin() {
@Override @Override
public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) {
builder.urlProcessor(new UrlProcessorInitialReadme()); builder.imageDestinationProcessor(new ImageDestinationProcessorInitialReadme());
} }
@Override @Override
@ -182,12 +182,12 @@ public class RecyclerActivity extends Activity {
return out; 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 static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/";
private final UrlProcessorRelativeToAbsolute processor private final ImageDestinationProcessorRelativeToAbsolute processor
= new UrlProcessorRelativeToAbsolute(GITHUB_BASE); = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE);
@NonNull @NonNull
@Override @Override

View File

@ -7,10 +7,11 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import io.noties.debug.Debug;
import io.noties.markwon.Markwon; 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.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.linkify.LinkifyPlugin;
import io.noties.markwon.sample.ActivityWithMenuOptions; import io.noties.markwon.sample.ActivityWithMenuOptions;
import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.MenuOptions;
@ -25,7 +26,9 @@ public class TableActivity extends ActivityWithMenuOptions {
public MenuOptions menuOptions() { public MenuOptions menuOptions() {
return MenuOptions.create() return MenuOptions.create()
.add("customize", this::customize) .add("customize", this::customize)
.add("tableAndLinkify", this::tableAndLinkify); .add("tableAndLinkify", this::tableAndLinkify)
.add("withImages", this::withImages)
.add("withLatex", this::withLatex);
} }
private TextView textView; private TextView textView;
@ -86,4 +89,47 @@ public class TableActivity extends ActivityWithMenuOptions {
markwon.setMarkdown(textView, md); 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);
}
} }

View File

@ -14,6 +14,7 @@
android:textAppearance="?android:attr/textAppearanceMedium" android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="#000" android:textColor="#000"
android:textSize="16sp" android:textSize="16sp"
android:padding="8dip"
tools:text="whatever" /> tools:text="whatever" />
</ScrollView> </ScrollView>