diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b280a34..c2b1767d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +# 4.3.0-SNAPSHOT +* add `MarkwonInlineParserPlugin` in `inline-parser` module +* `JLatexMathPlugin` now supports inline LaTeX structures via `MarkwonInlineParserPlugin` +dependency (must be explicitly added to `Markwon` whilst configuring) +* `JLatexMathPlugin`: add `theme` (to customize both inlines and blocks) +* add `JLatexMathPlugin.ErrorHandler` to catch latex rendering errors and (optionally) display error drawable ([#204]) +* `JLatexMathPlugin` add text color customization ([#207]) +* `JLatexMathPlugin` will use text color of widget in which it is displayed **if color is not set explicitly** +* add `SoftBreakAddsNewLinePlugin` plugin (`core` module) +* `LinkResolverDef` defaults to `https` when a link does not have scheme information ([#75]) +* add `option` abstraction for `sample` module allowing switching of multiple cases in runtime via menu +* non-empty bounds for `AsyncDrawable` when no dimensions are not yet available ([#189]) +* `linkify` - option to use `LinkifyCompat` in `LinkifyPlugin` ([#201]) +
Thanks to [@drakeet] +* `MarkwonVisitor.BlockHandler` and `BlockHandlerDef` implementation to control how blocks insert new lines after them + + +```java +// default usage: new blocks parser, no inlines +final Markwon markwon = Markwon.builder(this) + .usePlugin(JLatexMathPlugin.create(textSize)) + .build(); +``` + +```java +// legacy blocks (pre `4.3.0`) parsing, no inlines +final Markwon markwon = Markwon.builder(this) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> builder.blocksLegacy(true))) + .build(); +``` + +```java +// new blocks parsing and inline parsing +final Markwon markwon = Markwon.builder(this) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + // blocksEnabled and blocksLegacy can be omitted + builder + .blocksEnabled(true) + .blocksLegacy(false) + .inlinesEnabled(true); + })) + .build(); +``` + +[#189]: https://github.com/noties/Markwon/issues/189 +[#75]: https://github.com/noties/Markwon/issues/75 +[#204]: https://github.com/noties/Markwon/issues/204 +[#207]: https://github.com/noties/Markwon/issues/207 +[#201]: https://github.com/noties/Markwon/issues/201 +[@drakeet]: https://github.com/drakeet + + # 4.2.2 * Fixed `AsyncDrawable` display when it has placeholder with empty bounds ([#189]) * Fixed `syntax-highlight` where code input is empty string ([#192]) @@ -84,7 +136,7 @@ use `Markwon#builderNoCore()` to obtain a builder without `CorePlugin` * Added `MarkwonPlugin.Registry` and `MarkwonPlugin#configure(Registry)` method * `CorePlugin#addOnTextAddedListener` (process raw text added) * `ImageSizeResolver` signature change (accept `AsyncDrawable`) -* `LinkResolver` is now an independent entity (previously part of `LinkSpan`) +* `LinkResolver` is now an independent entity (previously part of the `LinkSpan`), `LinkSpan.Resolver` -> `LinkResolver` * `AsyncDrawableScheduler` can now be called multiple times without performance penalty * `AsyncDrawable` now exposes its destination, image-size, last known dimensions (canvas, text-size) * `AsyncDrawableLoader` signature change (accept `AsyncDrawable`) diff --git a/build.gradle b/build.gradle index 41a8b70b..43348c63 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,8 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + // on `3.5.3` tests are not run from CLI + classpath 'com.android.tools.build:gradle:3.5.2' classpath 'com.github.ben-manes:gradle-versions-plugin:0.27.0' } } @@ -16,6 +17,7 @@ allprojects { } google() jcenter() +// maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } } version = VERSION_NAME group = GROUP @@ -69,7 +71,7 @@ ext { 'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion", 'android-svg' : 'com.caverock:androidsvg:1.4', 'android-gif' : 'pl.droidsonroids.gif:android-gif-drawable:1.2.15', - 'jlatexmath-android' : 'ru.noties:jlatexmath-android:0.1.0', + 'jlatexmath-android' : 'ru.noties:jlatexmath-android:0.1.1', 'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0', 'prism4j' : 'io.noties:prism4j:2.0.0', 'debug' : 'io.noties:debug:5.0.0@jar', diff --git a/docs/.vuepress/public/assets/apps/purewriter.png b/docs/.vuepress/public/assets/apps/purewriter.png new file mode 100644 index 00000000..aab5623d Binary files /dev/null and b/docs/.vuepress/public/assets/apps/purewriter.png differ diff --git a/docs/README.md b/docs/README.md index 82143911..3b80abb4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -109,7 +109,8 @@ and 2 themes included: Light & Dark. It can be downloaded from [releases](ht diff --git a/docs/docs/v4/core/core-plugin.md b/docs/docs/v4/core/core-plugin.md index a5002ba8..7538ad1e 100644 --- a/docs/docs/v4/core/core-plugin.md +++ b/docs/docs/v4/core/core-plugin.md @@ -81,10 +81,15 @@ More information about props can be found [here](/docs/v4/core/render-props.md) --- :::tip Soft line break -Since Markwon core does not give an option to -insert a new line when there is a soft line break in markdown. Instead a -custom plugin can be used: +Since there is a dedicated plugin to insert a new line for +markdown soft breaks - `SoftBreakAddsNewLinePlugin`: +```java +final Markwon markwon = Markwon.builder(this) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .build(); +``` +It is still possible to do it manually with a custom visitor: ```java final Markwon markwon = Markwon.builder(this) .usePlugin(new AbstractMarkwonPlugin() { diff --git a/docs/docs/v4/core/visitor.md b/docs/docs/v4/core/visitor.md index d03ff848..6a0591ce 100644 --- a/docs/docs/v4/core/visitor.md +++ b/docs/docs/v4/core/visitor.md @@ -70,4 +70,33 @@ public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { } }); } +``` + +### BlockHandler + +Since there is class to control insertions of new lines after markdown blocks +`BlockHandler` (`MarkwonVisitor.BlockHandler`) and its default implementation `BlockHandlerDef`. For example, +to disable an empty new line after `Heading`: + +```java +final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(new BlockHandlerDef() { + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (node instanceof Heading) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + // ensure new line but do not force insert one + } + } else { + super.blockEnd(visitor, node); + } + } + }); + } + }) + .build(); ``` \ No newline at end of file diff --git a/docs/docs/v4/ext-latex/README.md b/docs/docs/v4/ext-latex/README.md index 16fde085..6e19dc45 100644 --- a/docs/docs/v4/ext-latex/README.md +++ b/docs/docs/v4/ext-latex/README.md @@ -2,51 +2,130 @@ -This is an extension that will help you display LaTeX formulas in your markdown. -Syntax is pretty simple: pre-fix and post-fix your latex with `$$` (double dollar sign). -`$$` should be the first characters in a line. +This is an extension that will help you display LaTeX content in your markdown. +Since supports both blocks and inlines markdown structures (blocks only before `4.3.0`). +## Blocks +Start a line with 2 (or more) `$` symbols followed by a new line: ```markdown $$ \\text{A long division \\longdiv{12345}{13} $$ ``` +LaTeX block content will be considered ended when a starting sequence of `$` is found on +a new line. If block was started with `$$$` it must be ended with `$$$` symbols. +## Inline +Exactly `$$` before and after _inline_ LaTeX content: ```markdown $$\\text{A long division \\longdiv{12345}{13}$$ ``` +:::warning +By default inline nodes are disabled and must be enabled explicitly: ```java -Markwon.builder(context) - .use(JLatexMathPlugin.create(textSize)) - .build(); +final Markwon markwon = Markwon.builder(this) + // required plugin to support inline parsing + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + // ENABLE inlines + builder.inlinesEnabled(true); + } + })) + .build(); ``` +Please note that usage of inline nodes **require** [MarkwonInlineParserPlugin](../inline-parser/) +::: This extension uses [jlatexmath-android](https://github.com/noties/jlatexmath-android) artifact to create LaTeX drawable. ## Config ```java -final Markwon markwon = Markwon.builder(context) - .usePlugin(JLatexMathPlugin.create(textSize, new BuilderConfigure() { +// create default instance of plugin and use specified text size for both blocks and inlines +JLatexMathPlugin.create(textView.getTextSize()); + +// create default instance of plugin and use specified text sizes +JLatexMathPlugin.create(inlineTextSize, blockTextSize); + +JLatexMathPlugin.create(textView.getTextSize(), new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + // enable inlines (require `MarkwonInlineParserPlugin`), by default `false` + builder.inlinesEnabled(true); + + // use pre-4.3.0 LaTeX block parsing (by default `false`) + builder.blocksLegacy(true); + + // by default true + builder.blocksEnabled(true); + + // @since 4.3.0 + builder.errorHandler(new JLatexMathPlugin.ErrorHandler() { + @Nullable @Override - public void configureBuilder(@NonNull Builder builder) { - builder - .align(JLatexMathDrawable.ALIGN_CENTER) - .fitCanvas(true) - .padding(paddingPx) - // @since 4.0.0 - horizontal and vertical padding - .padding(paddingHorizontalPx, paddingVerticalPx) - // @since 4.0.0 - change to provider - .backgroundProvider(() -> new MyDrawable())) - // @since 4.0.0 - optional, by default cached-thread-pool will be used - .executorService(Executors.newCachedThreadPool()); + public Drawable handleError(@NonNull String latex, @NonNull Throwable error) { + // Receive error and optionally return drawable to be displayed instead + return null; } - })) - .build(); + }); + + // executor on which parsing of LaTeX is done (by default `Executors.newCachedThreadPool()`) + builder.executorService(Executors.newCachedThreadPool()); + } +}); ``` +## Theme + +```java +JLatexMathPlugin.create(textView.getTextSize(), new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + + // background provider for both inlines and blocks + // or more specific: `inlineBackgroundProvider` & `blockBackgroundProvider` + builder.theme().backgroundProvider(new JLatexMathTheme.BackgroundProvider() { + @NonNull + @Override + public Drawable provide() { + return new ColorDrawable(0xFFff0000); + } + }); + + // should block fit the whole canvas width, by default true + builder.theme().blockFitCanvas(true); + + // horizontal alignment for block, by default ALIGN_CENTER + builder.theme().blockHorizontalAlignment(JLatexMathDrawable.ALIGN_CENTER); + + // padding for both inlines and blocks + builder.theme().padding(JLatexMathTheme.Padding.all(8)); + + // padding for inlines + builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(16, 8)); + + // padding for blocks + builder.theme().blockPadding(new JLatexMathTheme.Padding(0, 1, 2, 3)); + + // text color of LaTeX content for both inlines and blocks + // or more specific: `inlineTextColor` & `blockTextColor` + builder.theme().textColor(Color.RED); + } +}); +``` :::tip -Since `JLatexMathPlugin` operates independently of `ImagesPlugin` +Sometimes it is enough to use rendered to an image LaTeX formula and +inline it directly in your markdown document. For this markdown references can be useful. For example: +```markdown + +![markdown-reference] of a solution... + + +[markdown-reference]: data:image/svg+xml;base64,base64encodeddata== +``` +For this to work an image loader that supports data uri and base64 must be used. Default `Markwon` [image-loader](../image/) supports it out of box (including SVG support) ::: \ No newline at end of file diff --git a/docs/docs/v4/inline-parser/README.md b/docs/docs/v4/inline-parser/README.md index e9638832..67fa4de4 100644 --- a/docs/docs/v4/inline-parser/README.md +++ b/docs/docs/v4/inline-parser/README.md @@ -3,6 +3,16 @@ **Experimental** commonmark-java inline parser that allows customizing core features and/or extend with own. +:::tip +Since there is also `MarkwonInlineParserPlugin` which can be used +to allow other plugins to customize inline parser +```java +final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .build(); +``` +::: + Usage of _internal_ classes: ```java import org.commonmark.internal.Bracket; diff --git a/docs/docs/v4/install.md b/docs/docs/v4/install.md index c6de0e5a..d0f4e8e7 100644 --- a/docs/docs/v4/install.md +++ b/docs/docs/v4/install.md @@ -5,8 +5,18 @@ next: /docs/v4/core/getting-started.md # Installation -![stable](https://img.shields.io/maven-central/v/io.noties.markwon/core.svg?label=stable) -![snapshot](https://img.shields.io/nexus/s/https/oss.sonatype.org/io.noties.markwon/core.svg?label=snapshot) + + + + + + + + + + + +
stablechangelog
snapshotchangelog
diff --git a/gradle.properties b/gradle.properties index 5b0fd422..f74f7775 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.enableJetifier=true android.enableBuildCache=true android.buildCacheDir=build/pre-dex-cache -VERSION_NAME=4.2.2 +VERSION_NAME=4.3.0 GROUP=io.noties.markwon POM_DESCRIPTION=Markwon markdown for Android diff --git a/markwon-core/src/main/java/io/noties/markwon/BlockHandlerDef.java b/markwon-core/src/main/java/io/noties/markwon/BlockHandlerDef.java new file mode 100644 index 00000000..58567f43 --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/BlockHandlerDef.java @@ -0,0 +1,23 @@ +package io.noties.markwon; + +import androidx.annotation.NonNull; + +import org.commonmark.node.Node; + +/** + * @since 4.3.0 + */ +public class BlockHandlerDef implements MarkwonVisitor.BlockHandler { + @Override + public void blockStart(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + visitor.ensureNewLine(); + } + + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + visitor.forceNewLine(); + } + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/LinkResolverDef.java b/markwon-core/src/main/java/io/noties/markwon/LinkResolverDef.java index 999dcee5..5882613f 100644 --- a/markwon-core/src/main/java/io/noties/markwon/LinkResolverDef.java +++ b/markwon-core/src/main/java/io/noties/markwon/LinkResolverDef.java @@ -5,22 +5,41 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.provider.Browser; +import android.text.TextUtils; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; public class LinkResolverDef implements LinkResolver { + + // @since 4.3.0 + private static final String DEFAULT_SCHEME = "https"; + @Override public void resolve(@NonNull View view, @NonNull String link) { - final Uri uri = Uri.parse(link); + final Uri uri = parseLink(link); final Context context = view.getContext(); final Intent intent = new Intent(Intent.ACTION_VIEW, uri); intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); try { context.startActivity(intent); } catch (ActivityNotFoundException e) { - Log.w("LinkResolverDef", "Actvity was not found for intent, " + intent.toString()); + Log.w("LinkResolverDef", "Actvity was not found for the link: '" + link + "'"); } } + + /** + * @since 4.3.0 + */ + @NonNull + private static Uri parseLink(@NonNull String link) { + final Uri uri = Uri.parse(link); + if (TextUtils.isEmpty(uri.getScheme())) { + return uri.buildUpon() + .scheme(DEFAULT_SCHEME) + .build(); + } + return uri; + } } diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitor.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitor.java index 9fd6ffc7..2f69acf6 100644 --- a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitor.java +++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitor.java @@ -23,6 +23,19 @@ public interface MarkwonVisitor extends Visitor { void visit(@NonNull MarkwonVisitor visitor, @NonNull N n); } + /** + * Primary purpose is to control the spacing applied before/after certain blocks, which + * visitors are created elsewhere + * + * @since 4.3.0 + */ + interface BlockHandler { + + void blockStart(@NonNull MarkwonVisitor visitor, @NonNull Node node); + + void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node); + } + interface Builder { /** @@ -33,6 +46,16 @@ public interface MarkwonVisitor extends Visitor { @NonNull Builder on(@NonNull Class node, @Nullable NodeVisitor nodeVisitor); + /** + * @param blockHandler to handle block start/end + * @see BlockHandler + * @see BlockHandlerDef + * @since 4.3.0 + */ + @SuppressWarnings("UnusedReturnValue") + @NonNull + Builder blockHandler(@NonNull BlockHandler blockHandler); + @NonNull MarkwonVisitor build(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps); } @@ -133,4 +156,14 @@ public interface MarkwonVisitor extends Visitor { */ @SuppressWarnings("unused") void setSpansForNodeOptional(@NonNull Class node, int start); + + /** + * @since 4.3.0 + */ + void blockStart(@NonNull Node node); + + /** + * @since 4.3.0 + */ + void blockEnd(@NonNull Node node); } diff --git a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java index 659a6622..ce116111 100644 --- a/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java +++ b/markwon-core/src/main/java/io/noties/markwon/MarkwonVisitorImpl.java @@ -45,15 +45,20 @@ class MarkwonVisitorImpl implements MarkwonVisitor { private final Map, NodeVisitor> nodes; + // @since 4.3.0 + private final BlockHandler blockHandler; + MarkwonVisitorImpl( @NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull SpannableBuilder builder, - @NonNull Map, NodeVisitor> nodes) { + @NonNull Map, NodeVisitor> nodes, + @NonNull BlockHandler blockHandler) { this.configuration = configuration; this.renderProps = renderProps; this.builder = builder; this.nodes = nodes; + this.blockHandler = blockHandler; } @Override @@ -268,9 +273,20 @@ class MarkwonVisitorImpl implements MarkwonVisitor { } } + @Override + public void blockStart(@NonNull Node node) { + blockHandler.blockStart(this, node); + } + + @Override + public void blockEnd(@NonNull Node node) { + blockHandler.blockEnd(this, node); + } + static class BuilderImpl implements Builder { private final Map, NodeVisitor> nodes = new HashMap<>(); + private BlockHandler blockHandler; @NonNull @Override @@ -290,14 +306,28 @@ class MarkwonVisitorImpl implements MarkwonVisitor { return this; } + @NonNull + @Override + public Builder blockHandler(@NonNull BlockHandler blockHandler) { + this.blockHandler = blockHandler; + return this; + } + @NonNull @Override public MarkwonVisitor build(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps) { + // @since 4.3.0 + BlockHandler blockHandler = this.blockHandler; + if (blockHandler == null) { + blockHandler = new BlockHandlerDef(); + } + return new MarkwonVisitorImpl( configuration, renderProps, new SpannableBuilder(), - Collections.unmodifiableMap(nodes)); + Collections.unmodifiableMap(nodes), + blockHandler); } } } diff --git a/markwon-core/src/main/java/io/noties/markwon/SoftBreakAddsNewLinePlugin.java b/markwon-core/src/main/java/io/noties/markwon/SoftBreakAddsNewLinePlugin.java new file mode 100644 index 00000000..9c7572fb --- /dev/null +++ b/markwon-core/src/main/java/io/noties/markwon/SoftBreakAddsNewLinePlugin.java @@ -0,0 +1,26 @@ +package io.noties.markwon; + +import androidx.annotation.NonNull; + +import org.commonmark.node.SoftLineBreak; + +/** + * @since 4.3.0 + */ +public class SoftBreakAddsNewLinePlugin extends AbstractMarkwonPlugin { + + @NonNull + public static SoftBreakAddsNewLinePlugin create() { + return new SoftBreakAddsNewLinePlugin(); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(SoftLineBreak.class, new MarkwonVisitor.NodeVisitor() { + @Override + public void visit(@NonNull MarkwonVisitor visitor, @NonNull SoftLineBreak softLineBreak) { + visitor.ensureNewLine(); + } + }); + } +} diff --git a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java index dffa215d..29c63a2a 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/CorePlugin.java @@ -210,17 +210,14 @@ public class CorePlugin extends AbstractMarkwonPlugin { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull BlockQuote blockQuote) { - visitor.ensureNewLine(); + visitor.blockStart(blockQuote); final int length = visitor.length(); visitor.visitChildren(blockQuote); visitor.setSpansForNodeOptional(blockQuote, length); - if (visitor.hasNext(blockQuote)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } + visitor.blockEnd(blockQuote); } }); } @@ -316,7 +313,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { @NonNull String code, @NonNull Node node) { - visitor.ensureNewLine(); + visitor.blockStart(node); final int length = visitor.length(); @@ -333,10 +330,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { visitor.setSpansForNodeOptional(node, length); - if (visitor.hasNext(node)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } + visitor.blockEnd(node); } private static void bulletList(@NonNull MarkwonVisitor.Builder builder) { @@ -402,7 +396,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull ThematicBreak thematicBreak) { - visitor.ensureNewLine(); + visitor.blockStart(thematicBreak); final int length = visitor.length(); @@ -411,10 +405,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { visitor.setSpansForNodeOptional(thematicBreak, length); - if (visitor.hasNext(thematicBreak)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } + visitor.blockEnd(thematicBreak); } }); } @@ -424,7 +415,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull Heading heading) { - visitor.ensureNewLine(); + visitor.blockStart(heading); final int length = visitor.length(); visitor.visitChildren(heading); @@ -433,10 +424,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { visitor.setSpansForNodeOptional(heading, length); - if (visitor.hasNext(heading)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } + visitor.blockEnd(heading); } }); } @@ -467,7 +455,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { final boolean inTightList = isInTightList(paragraph); if (!inTightList) { - visitor.ensureNewLine(); + visitor.blockStart(paragraph); } final int length = visitor.length(); @@ -478,9 +466,8 @@ public class CorePlugin extends AbstractMarkwonPlugin { // @since 1.1.1 apply paragraph span visitor.setSpansForNodeOptional(paragraph, length); - if (!inTightList && visitor.hasNext(paragraph)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); + if (!inTightList) { + visitor.blockEnd(paragraph); } } }); diff --git a/markwon-core/src/main/java/io/noties/markwon/core/SimpleBlockNodeVisitor.java b/markwon-core/src/main/java/io/noties/markwon/core/SimpleBlockNodeVisitor.java index 91742773..6a27599e 100644 --- a/markwon-core/src/main/java/io/noties/markwon/core/SimpleBlockNodeVisitor.java +++ b/markwon-core/src/main/java/io/noties/markwon/core/SimpleBlockNodeVisitor.java @@ -17,19 +17,16 @@ public class SimpleBlockNodeVisitor implements MarkwonVisitor.NodeVisitor @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + visitor.blockStart(node); + // @since 3.0.1 we keep track of start in order to apply spans (optionally) final int length = visitor.length(); - visitor.ensureNewLine(); - visitor.visitChildren(node); // @since 3.0.1 we apply optional spans visitor.setSpansForNodeOptional(node, length); - if (visitor.hasNext(node)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } + visitor.blockEnd(node); } } diff --git a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawable.java b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawable.java index ad247238..8447110a 100644 --- a/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawable.java +++ b/markwon-core/src/main/java/io/noties/markwon/image/AsyncDrawable.java @@ -223,6 +223,7 @@ public class AsyncDrawable extends Drawable { } this.result = result; +// this.result.setCallback(callback); initBounds(); } @@ -250,6 +251,10 @@ public class AsyncDrawable extends Drawable { if (canvasWidth == 0) { // we still have no bounds - wait for them waitingForDimensions = true; + + // we cannot have empty bounds - otherwise in case if text contains + // a single AsyncDrawableSpan, it won't be displayed + setBounds(noDimensionsBounds(result)); return; } @@ -268,6 +273,24 @@ public class AsyncDrawable extends Drawable { invalidateSelf(); } + /** + * @since 4.3.0 + */ + @NonNull + private static Rect noDimensionsBounds(@Nullable Drawable result) { + if (result != null) { + final Rect bounds = result.getBounds(); + if (!bounds.isEmpty()) { + return bounds; + } + final Rect intrinsicBounds = DrawableUtils.intrinsicBounds(result); + if (!intrinsicBounds.isEmpty()) { + return intrinsicBounds; + } + } + return new Rect(0, 0, 1, 1); + } + /** * @since 1.0.1 */ diff --git a/markwon-core/src/test/java/io/noties/markwon/AbstractMarkwonVisitorImpl.java b/markwon-core/src/test/java/io/noties/markwon/AbstractMarkwonVisitorImpl.java index ed4dae6b..5bae81f9 100644 --- a/markwon-core/src/test/java/io/noties/markwon/AbstractMarkwonVisitorImpl.java +++ b/markwon-core/src/test/java/io/noties/markwon/AbstractMarkwonVisitorImpl.java @@ -12,7 +12,8 @@ public class AbstractMarkwonVisitorImpl extends MarkwonVisitorImpl { @NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull SpannableBuilder spannableBuilder, - @NonNull Map, NodeVisitor> nodes) { - super(configuration, renderProps, spannableBuilder, nodes); + @NonNull Map, NodeVisitor> nodes, + @NonNull BlockHandler blockHandler) { + super(configuration, renderProps, spannableBuilder, nodes, blockHandler); } } diff --git a/markwon-core/src/test/java/io/noties/markwon/LinkResolverDefTest.java b/markwon-core/src/test/java/io/noties/markwon/LinkResolverDefTest.java new file mode 100644 index 00000000..eb19d933 --- /dev/null +++ b/markwon-core/src/test/java/io/noties/markwon/LinkResolverDefTest.java @@ -0,0 +1,79 @@ +package io.noties.markwon; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class LinkResolverDefTest { + + @Test + public void no_scheme_https() { + // when supplied url doesn't have scheme fallback to `https` + + // must be => `https://www.markw.on + final String link = "www.markw.on"; + + final Uri uri = resolve(link); + + final String scheme = uri.getScheme(); + assertNotNull(uri.toString(), scheme); + + assertEquals(uri.toString(), "https", scheme); + } + + @Test + public void scheme_present() { + // when scheme is present, it won't be touched + + final String link = "whatnot://hey/ho"; + + final Uri uri = resolve(link); + + final String scheme = uri.getScheme(); + assertEquals(uri.toString(), "whatnot", scheme); + + assertEquals(Uri.parse(link), uri); + } + + // we could call `parseLink` directly, but this doesn't mean LinkResolverDef uses it + @NonNull + private Uri resolve(@NonNull String link) { + final View view = mock(View.class); + final Context context = mock(Context.class); + when(view.getContext()).thenReturn(context); + + final LinkResolverDef def = new LinkResolverDef(); + def.resolve(view, link); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + + verify(context, times(1)) + .startActivity(captor.capture()); + + final Intent intent = captor.getValue(); + assertNotNull(intent); + + final Uri uri = intent.getData(); + assertNotNull(uri); + + return uri; + } +} \ No newline at end of file diff --git a/markwon-core/src/test/java/io/noties/markwon/MarkwonVisitorImplTest.java b/markwon-core/src/test/java/io/noties/markwon/MarkwonVisitorImplTest.java index 45387afb..6c6c93ba 100644 --- a/markwon-core/src/test/java/io/noties/markwon/MarkwonVisitorImplTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/MarkwonVisitorImplTest.java @@ -43,7 +43,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), renderProps, spannableBuilder, - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); impl.clear(); @@ -61,7 +62,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), mock(RenderProps.class), builder, - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); // at the start - won't add anything impl.ensureNewLine(); @@ -92,7 +94,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), mock(RenderProps.class), builder, - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); assertEquals(0, builder.length()); @@ -144,7 +147,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), mock(RenderProps.class), mock(SpannableBuilder.class), - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); final BlockQuote node = mock(BlockQuote.class); final Node child = mock(Node.class); @@ -163,7 +167,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), mock(RenderProps.class), mock(SpannableBuilder.class), - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); final Node noNext = mock(Node.class); assertFalse(impl.hasNext(noNext)); @@ -195,7 +200,8 @@ public class MarkwonVisitorImplTest { mock(MarkwonConfiguration.class), mock(RenderProps.class), builder, - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); for (int i = 0; i < 13; i++) { builder.setLength(i); @@ -221,7 +227,8 @@ public class MarkwonVisitorImplTest { configuration, mock(RenderProps.class), mock(SpannableBuilder.class), - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); impl.setSpansForNode(Node.class, 0); @@ -252,7 +259,8 @@ public class MarkwonVisitorImplTest { configuration, mock(RenderProps.class), builder, - Collections., MarkwonVisitor.NodeVisitor>emptyMap()); + Collections., MarkwonVisitor.NodeVisitor>emptyMap(), + mock(MarkwonVisitor.BlockHandler.class)); // append something builder.append("no-spans-test"); diff --git a/markwon-core/src/test/java/io/noties/markwon/core/CorePluginTest.java b/markwon-core/src/test/java/io/noties/markwon/core/CorePluginTest.java index 9cb95028..56bc539c 100644 --- a/markwon-core/src/test/java/io/noties/markwon/core/CorePluginTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/core/CorePluginTest.java @@ -107,6 +107,12 @@ public class CorePluginTest { return this; } + @NonNull + @Override + public MarkwonVisitor.Builder blockHandler(@NonNull MarkwonVisitor.BlockHandler blockHandler) { + throw new RuntimeException(); + } + @NonNull @Override public MarkwonVisitor build(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps) { diff --git a/markwon-core/src/test/java/io/noties/markwon/syntax/SyntaxHighlightTest.java b/markwon-core/src/test/java/io/noties/markwon/syntax/SyntaxHighlightTest.java index fdde1888..3ff6df03 100644 --- a/markwon-core/src/test/java/io/noties/markwon/syntax/SyntaxHighlightTest.java +++ b/markwon-core/src/test/java/io/noties/markwon/syntax/SyntaxHighlightTest.java @@ -91,7 +91,8 @@ public class SyntaxHighlightTest { configuration, mock(RenderProps.class), new SpannableBuilder(), - visitorMap); + visitorMap, + mock(MarkwonVisitor.BlockHandler.class)); final SpannableBuilder builder = visitor.builder(); diff --git a/markwon-ext-latex/build.gradle b/markwon-ext-latex/build.gradle index 6e684440..b0d3fc92 100644 --- a/markwon-ext-latex/build.gradle +++ b/markwon-ext-latex/build.gradle @@ -16,6 +16,7 @@ android { dependencies { api project(':markwon-core') + api project(':markwon-inline-parser') api deps['jlatexmath-android'] diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexAsyncDrawableSpan.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexAsyncDrawableSpan.java new file mode 100644 index 00000000..fa08ba0c --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexAsyncDrawableSpan.java @@ -0,0 +1,62 @@ +package io.noties.markwon.ext.latex; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +import org.scilab.forge.jlatexmath.TeXIcon; + +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.image.AsyncDrawableSpan; +import ru.noties.jlatexmath.JLatexMathDrawable; +import ru.noties.jlatexmath.awt.Color; + +/** + * @since 4.3.0 + */ +public class JLatexAsyncDrawableSpan extends AsyncDrawableSpan { + + private final JLatextAsyncDrawable drawable; + private final int color; + private boolean appliedTextColor; + + public JLatexAsyncDrawableSpan( + @NonNull MarkwonTheme theme, + @NonNull JLatextAsyncDrawable drawable, + @ColorInt int color) { + super(theme, drawable, ALIGN_CENTER, false); + this.drawable = drawable; + this.color = color; + // if color is not 0 -> then no need to apply text color + this.appliedTextColor = color != 0; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) { + if (!appliedTextColor && drawable.hasResult()) { + // it is important to check for type (in case of an error, or custom placeholder or whatever + // this result can be of other type) + final Drawable drawableResult = drawable.getResult(); + if (drawableResult instanceof JLatexMathDrawable) { + final JLatexMathDrawable result = (JLatexMathDrawable) drawableResult; + final TeXIcon icon = result.icon(); + icon.setForeground(new Color(paint.getColor())); + appliedTextColor = true; + } + } + super.draw(canvas, text, start, end, x, top, y, bottom, paint); + } + + @NonNull + public JLatextAsyncDrawable drawable() { + return drawable; + } + + @ColorInt + public int color() { + return color; + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexBlockImageSizeResolver.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexBlockImageSizeResolver.java new file mode 100644 index 00000000..7e9ac95c --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexBlockImageSizeResolver.java @@ -0,0 +1,50 @@ +package io.noties.markwon.ext.latex; + +import android.graphics.Rect; + +import androidx.annotation.NonNull; + +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.ImageSizeResolver; + +// we must make drawable fit canvas (if specified), but do not keep the ratio whilst scaling up +// @since 4.0.0 +class JLatexBlockImageSizeResolver extends ImageSizeResolver { + + private final boolean fitCanvas; + + JLatexBlockImageSizeResolver(boolean fitCanvas) { + this.fitCanvas = fitCanvas; + } + + @NonNull + @Override + public Rect resolveImageSize(@NonNull AsyncDrawable drawable) { + + final Rect imageBounds = drawable.getResult().getBounds(); + final int canvasWidth = drawable.getLastKnownCanvasWidth(); + + if (fitCanvas) { + + // we modify bounds only if `fitCanvas` is true + final int w = imageBounds.width(); + + if (w < canvasWidth) { + // increase width and center formula (keep height as-is) + return new Rect(0, 0, canvasWidth, imageBounds.height()); + } + + // @since 4.0.2 we additionally scale down the resulting formula (keeping the ratio) + // the thing is - JLatexMathDrawable will do it anyway, but it will modify its own + // bounds (which AsyncDrawable won't catch), thus leading to an empty space after the formula + if (w > canvasWidth) { + // here we must scale it down (keeping the ratio) + final float ratio = (float) w / imageBounds.height(); + final int h = (int) (canvasWidth / ratio + .5F); + return new Rect(0, 0, canvasWidth, h); + } + } + + return imageBounds; + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexInlineAsyncDrawableSpan.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexInlineAsyncDrawableSpan.java new file mode 100644 index 00000000..0edda9d3 --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexInlineAsyncDrawableSpan.java @@ -0,0 +1,61 @@ +package io.noties.markwon.ext.latex; + +import android.graphics.Paint; +import android.graphics.Rect; + +import androidx.annotation.ColorInt; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.noties.markwon.core.MarkwonTheme; +import io.noties.markwon.image.AsyncDrawable; + +/** + * @since 4.3.0 + */ +class JLatexInlineAsyncDrawableSpan extends JLatexAsyncDrawableSpan { + + private final AsyncDrawable drawable; + + JLatexInlineAsyncDrawableSpan(@NonNull MarkwonTheme theme, @NonNull JLatextAsyncDrawable drawable, @ColorInt int color) { + super(theme, drawable, color); + this.drawable = drawable; + } + + @Override + public int getSize( + @NonNull Paint paint, + CharSequence text, + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + @Nullable Paint.FontMetricsInt fm) { + + // if we have no async drawable result - we will just render text + + final int size; + + if (drawable.hasResult()) { + + final Rect rect = drawable.getBounds(); + + if (fm != null) { + final int half = rect.bottom / 2; + fm.ascent = -half; + fm.descent = half; + + fm.top = fm.ascent; + fm.bottom = 0; + } + + size = rect.right; + + } else { + + // NB, no specific text handling (no new lines, etc) + size = (int) (paint.measureText(text, start, end) + .5F); + } + + return size; + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParser.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParser.java index 8f54f245..cf212e32 100644 --- a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParser.java +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParser.java @@ -1,5 +1,8 @@ package io.noties.markwon.ext.latex; +import androidx.annotation.NonNull; + +import org.commonmark.internal.util.Parsing; import org.commonmark.node.Block; import org.commonmark.parser.block.AbstractBlockParser; import org.commonmark.parser.block.AbstractBlockParserFactory; @@ -8,13 +11,24 @@ import org.commonmark.parser.block.BlockStart; import org.commonmark.parser.block.MatchedBlockParser; import org.commonmark.parser.block.ParserState; -public class JLatexMathBlockParser extends AbstractBlockParser { +/** + * @since 4.3.0 (although there was a class with the same name, + * which is renamed now to {@link JLatexMathBlockParserLegacy}) + */ +class JLatexMathBlockParser extends AbstractBlockParser { + + private static final char DOLLAR = '$'; + private static final char SPACE = ' '; private final JLatexMathBlock block = new JLatexMathBlock(); private final StringBuilder builder = new StringBuilder(); - private boolean isClosed; + private final int signs; + + JLatexMathBlockParser(int signs) { + this.signs = signs; + } @Override public Block getBlock() { @@ -23,9 +37,19 @@ public class JLatexMathBlockParser extends AbstractBlockParser { @Override public BlockContinue tryContinue(ParserState parserState) { + final int nextNonSpaceIndex = parserState.getNextNonSpaceIndex(); + final CharSequence line = parserState.getLine(); + final int length = line.length(); - if (isClosed) { - return BlockContinue.finished(); + // check for closing + if (parserState.getIndent() < Parsing.CODE_BLOCK_INDENT) { + if (consume(DOLLAR, line, nextNonSpaceIndex, length) == signs) { + // okay, we have our number of signs + // let's consume spaces until the end + if (Parsing.skip(SPACE, line, nextNonSpaceIndex + signs, length) == length) { + return BlockContinue.finished(); + } + } } return BlockContinue.atIndex(parserState.getIndex()); @@ -33,21 +57,8 @@ public class JLatexMathBlockParser extends AbstractBlockParser { @Override public void addLine(CharSequence line) { - - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(line); - - final int length = builder.length(); - if (length > 1) { - isClosed = '$' == builder.charAt(length - 1) - && '$' == builder.charAt(length - 2); - if (isClosed) { - builder.replace(length - 2, length, ""); - } - } + builder.append('\n'); } @Override @@ -60,20 +71,49 @@ public class JLatexMathBlockParser extends AbstractBlockParser { @Override public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { - final CharSequence line = state.getLine(); - final int length = line != null - ? line.length() - : 0; + // let's define the spec: + // * 0-3 spaces before are allowed (Parsing.CODE_BLOCK_INDENT = 4) + // * 2+ subsequent `$` signs + // * any optional amount of spaces + // * new line + // * block is closed when the same amount of opening signs is met - if (length > 1) { - if ('$' == line.charAt(0) - && '$' == line.charAt(1)) { - return BlockStart.of(new JLatexMathBlockParser()) - .atIndex(state.getIndex() + 2); - } + final int indent = state.getIndent(); + + // check if it's an indented code block + if (indent >= Parsing.CODE_BLOCK_INDENT) { + return BlockStart.none(); } - return BlockStart.none(); + final int nextNonSpaceIndex = state.getNextNonSpaceIndex(); + final CharSequence line = state.getLine(); + final int length = line.length(); + + final int signs = consume(DOLLAR, line, nextNonSpaceIndex, length); + + // 2 is minimum + if (signs < 2) { + return BlockStart.none(); + } + + // consume spaces until the end of the line, if any other content is found -> NONE + if (Parsing.skip(SPACE, line, nextNonSpaceIndex + signs, length) != length) { + return BlockStart.none(); + } + + return BlockStart.of(new JLatexMathBlockParser(signs)) + .atIndex(length + 1); } } + + @SuppressWarnings("SameParameterValue") + private static int consume(char c, @NonNull CharSequence line, int start, int end) { + for (int i = start; i < end; i++) { + if (c != line.charAt(i)) { + return i - start; + } + } + // all consumed + return end - start; + } } diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParserLegacy.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParserLegacy.java new file mode 100644 index 00000000..9b4565bc --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathBlockParserLegacy.java @@ -0,0 +1,82 @@ +package io.noties.markwon.ext.latex; + +import org.commonmark.node.Block; +import org.commonmark.parser.block.AbstractBlockParser; +import org.commonmark.parser.block.AbstractBlockParserFactory; +import org.commonmark.parser.block.BlockContinue; +import org.commonmark.parser.block.BlockStart; +import org.commonmark.parser.block.MatchedBlockParser; +import org.commonmark.parser.block.ParserState; + +/** + * @since 4.3.0 (although it is just renamed parser from previous versions) + */ +class JLatexMathBlockParserLegacy extends AbstractBlockParser { + + private final JLatexMathBlock block = new JLatexMathBlock(); + + private final StringBuilder builder = new StringBuilder(); + + private boolean isClosed; + + @Override + public Block getBlock() { + return block; + } + + @Override + public BlockContinue tryContinue(ParserState parserState) { + + if (isClosed) { + return BlockContinue.finished(); + } + + return BlockContinue.atIndex(parserState.getIndex()); + } + + @Override + public void addLine(CharSequence line) { + + if (builder.length() > 0) { + builder.append('\n'); + } + + builder.append(line); + + final int length = builder.length(); + if (length > 1) { + isClosed = '$' == builder.charAt(length - 1) + && '$' == builder.charAt(length - 2); + if (isClosed) { + builder.replace(length - 2, length, ""); + } + } + } + + @Override + public void closeBlock() { + block.latex(builder.toString()); + } + + public static class Factory extends AbstractBlockParserFactory { + + @Override + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + + final CharSequence line = state.getLine(); + final int length = line != null + ? line.length() + : 0; + + if (length > 1) { + if ('$' == line.charAt(0) + && '$' == line.charAt(1)) { + return BlockStart.of(new JLatexMathBlockParserLegacy()) + .atIndex(state.getIndex() + 2); + } + } + + return BlockStart.none(); + } + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathInlineProcessor.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathInlineProcessor.java new file mode 100644 index 00000000..d368778c --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathInlineProcessor.java @@ -0,0 +1,36 @@ +package io.noties.markwon.ext.latex; + +import androidx.annotation.Nullable; + +import org.commonmark.node.Node; + +import java.util.regex.Pattern; + +import io.noties.markwon.inlineparser.InlineProcessor; + +/** + * @since 4.3.0 + */ +class JLatexMathInlineProcessor extends InlineProcessor { + + private static final Pattern RE = Pattern.compile("(\\${2})([\\s\\S]+?)\\1"); + + @Override + public char specialCharacter() { + return '$'; + } + + @Nullable + @Override + protected Node parse() { + + final String latex = match(RE); + if (latex == null) { + return null; + } + + final JLatexMathNode node = new JLatexMathNode(); + node.latex(latex.substring(2, latex.length() - 2)); + return node; + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathNode.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathNode.java new file mode 100644 index 00000000..46948cc5 --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathNode.java @@ -0,0 +1,19 @@ +package io.noties.markwon.ext.latex; + +import org.commonmark.node.CustomNode; + +/** + * @since 4.3.0 + */ +public class JLatexMathNode extends CustomNode { + + private String latex; + + public String latex() { + return latex; + } + + public void latex(String latex) { + this.latex = latex; + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java index 5d136ece..87456358 100644 --- a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathPlugin.java @@ -29,7 +29,9 @@ import io.noties.markwon.image.AsyncDrawable; import io.noties.markwon.image.AsyncDrawableLoader; import io.noties.markwon.image.AsyncDrawableScheduler; import io.noties.markwon.image.AsyncDrawableSpan; +import io.noties.markwon.image.DrawableUtils; import io.noties.markwon.image.ImageSizeResolver; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import ru.noties.jlatexmath.JLatexMathDrawable; /** @@ -38,11 +40,18 @@ import ru.noties.jlatexmath.JLatexMathDrawable; public class JLatexMathPlugin extends AbstractMarkwonPlugin { /** - * @since 4.0.0 + * @since 4.3.0 */ - public interface BackgroundProvider { - @NonNull - Drawable provide(); + public interface ErrorHandler { + + /** + * @param latex that caused the error + * @param error occurred + * @return (optional) error drawable that will be used instead (if drawable will have bounds + * it will be used, if not intrinsic bounds will be set) + */ + @Nullable + Drawable handleError(@NonNull String latex, @NonNull Throwable error); } public interface BuilderConfigure { @@ -54,52 +63,74 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { return new JLatexMathPlugin(builder(textSize).build()); } + /** + * @since 4.3.0 + */ + @NonNull + public static JLatexMathPlugin create(@Px float inlineTextSize, @Px float blockTextSize) { + return new JLatexMathPlugin(builder(inlineTextSize, blockTextSize).build()); + } + @NonNull public static JLatexMathPlugin create(@NonNull Config config) { return new JLatexMathPlugin(config); } @NonNull - public static JLatexMathPlugin create(float textSize, @NonNull BuilderConfigure builderConfigure) { - final Builder builder = new Builder(textSize); + public static JLatexMathPlugin create(@Px float textSize, @NonNull BuilderConfigure builderConfigure) { + final Builder builder = builder(textSize); + builderConfigure.configureBuilder(builder); + return new JLatexMathPlugin(builder.build()); + } + + /** + * @since 4.3.0 + */ + @NonNull + public static JLatexMathPlugin create( + @Px float inlineTextSize, + @Px float blockTextSize, + @NonNull BuilderConfigure builderConfigure) { + final Builder builder = builder(inlineTextSize, blockTextSize); builderConfigure.configureBuilder(builder); return new JLatexMathPlugin(builder.build()); } @NonNull - public static JLatexMathPlugin.Builder builder(float textSize) { - return new Builder(textSize); + public static JLatexMathPlugin.Builder builder(@Px float textSize) { + return new Builder(JLatexMathTheme.builder(textSize)); } - public static class Config { + /** + * @since 4.3.0 + */ + @NonNull + public static JLatexMathPlugin.Builder builder(@Px float inlineTextSize, @Px float blockTextSize) { + return new Builder(JLatexMathTheme.builder(inlineTextSize, blockTextSize)); + } - private final float textSize; + @VisibleForTesting + static class Config { - // @since 4.0.0 - private final BackgroundProvider backgroundProvider; + // @since 4.3.0 + final JLatexMathTheme theme; - @JLatexMathDrawable.Align - private final int align; + // @since 4.3.0 + final boolean blocksEnabled; + final boolean blocksLegacy; + final boolean inlinesEnabled; - private final boolean fitCanvas; + // @since 4.3.0 + final ErrorHandler errorHandler; - // @since 4.0.0 - private final int paddingHorizontal; - - // @since 4.0.0 - private final int paddingVertical; - - // @since 4.0.0 - private final ExecutorService executorService; + final ExecutorService executorService; Config(@NonNull Builder builder) { - this.textSize = builder.textSize; - this.backgroundProvider = builder.backgroundProvider; - this.align = builder.align; - this.fitCanvas = builder.fitCanvas; - this.paddingHorizontal = builder.paddingHorizontal; - this.paddingVertical = builder.paddingVertical; - + this.theme = builder.theme.build(); + this.blocksEnabled = builder.blocksEnabled; + this.blocksLegacy = builder.blocksLegacy; + this.inlinesEnabled = builder.inlinesEnabled; + this.errorHandler = builder.errorHandler; // @since 4.0.0 ExecutorService executorService = builder.executorService; if (executorService == null) { @@ -109,26 +140,59 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { } } + @VisibleForTesting + final Config config; + private final JLatextAsyncDrawableLoader jLatextAsyncDrawableLoader; - private final JLatexImageSizeResolver jLatexImageSizeResolver; + private final JLatexBlockImageSizeResolver jLatexBlockImageSizeResolver; + private final ImageSizeResolver inlineImageSizeResolver; @SuppressWarnings("WeakerAccess") JLatexMathPlugin(@NonNull Config config) { + this.config = config; this.jLatextAsyncDrawableLoader = new JLatextAsyncDrawableLoader(config); - this.jLatexImageSizeResolver = new JLatexImageSizeResolver(config.fitCanvas); + this.jLatexBlockImageSizeResolver = new JLatexBlockImageSizeResolver(config.theme.blockFitCanvas()); + this.inlineImageSizeResolver = new InlineImageSizeResolver(); + } + + @Override + public void configure(@NonNull Registry registry) { + if (config.inlinesEnabled) { + registry.require(MarkwonInlineParserPlugin.class) + .factoryBuilder() + .addInlineProcessor(new JLatexMathInlineProcessor()); + } } @Override public void configureParser(@NonNull Parser.Builder builder) { - builder.customBlockParserFactory(new JLatexMathBlockParser.Factory()); + // @since 4.3.0 + if (config.blocksEnabled) { + if (config.blocksLegacy) { + builder.customBlockParserFactory(new JLatexMathBlockParserLegacy.Factory()); + } else { + builder.customBlockParserFactory(new JLatexMathBlockParser.Factory()); + } + } } @Override public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + addBlockVisitor(builder); + addInlineVisitor(builder); + } + + private void addBlockVisitor(@NonNull MarkwonVisitor.Builder builder) { + if (!config.blocksEnabled) { + return; + } + builder.on(JLatexMathBlock.class, new MarkwonVisitor.NodeVisitor() { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull JLatexMathBlock jLatexMathBlock) { + visitor.blockStart(jLatexMathBlock); + final String latex = jLatexMathBlock.latex(); final int length = visitor.length(); @@ -140,15 +204,54 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { final MarkwonConfiguration configuration = visitor.configuration(); - final AsyncDrawableSpan span = new AsyncDrawableSpan( + final AsyncDrawableSpan span = new JLatexAsyncDrawableSpan( configuration.theme(), - new AsyncDrawable( + new JLatextAsyncDrawable( latex, jLatextAsyncDrawableLoader, - jLatexImageSizeResolver, - null), - AsyncDrawableSpan.ALIGN_BOTTOM, - false); + jLatexBlockImageSizeResolver, + null, + true), + config.theme.blockTextColor() + ); + + visitor.setSpans(length, span); + + visitor.blockEnd(jLatexMathBlock); + } + }); + } + + private void addInlineVisitor(@NonNull MarkwonVisitor.Builder builder) { + + if (!config.inlinesEnabled) { + return; + } + + builder.on(JLatexMathNode.class, new MarkwonVisitor.NodeVisitor() { + @Override + public void visit(@NonNull MarkwonVisitor visitor, @NonNull JLatexMathNode jLatexMathNode) { + final String latex = jLatexMathNode.latex(); + + final int length = visitor.length(); + + // @since 4.0.2 we cannot append _raw_ latex as a placeholder-text, + // because Android will draw formula for each line of text, thus + // leading to formula duplicated (drawn on each line of text) + visitor.builder().append(prepareLatexTextPlaceholder(latex)); + + final MarkwonConfiguration configuration = visitor.configuration(); + + final AsyncDrawableSpan span = new JLatexInlineAsyncDrawableSpan( + configuration.theme(), + new JLatextAsyncDrawable( + latex, + jLatextAsyncDrawableLoader, + inlineImageSizeResolver, + null, + false), + config.theme.inlineTextColor() + ); visitor.setSpans(length, span); } @@ -172,69 +275,72 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { return latex.replace('\n', ' ').trim(); } + @SuppressWarnings({"unused", "UnusedReturnValue"}) public static class Builder { - private final float textSize; + // @since 4.3.0 + private final JLatexMathTheme.Builder theme; - // @since 4.0.0 - private BackgroundProvider backgroundProvider; + // @since 4.3.0 + private boolean blocksEnabled = true; + private boolean blocksLegacy; + private boolean inlinesEnabled; - @JLatexMathDrawable.Align - private int align = JLatexMathDrawable.ALIGN_CENTER; - - private boolean fitCanvas = true; - - // @since 4.0.0 - private int paddingHorizontal; - - // @since 4.0.0 - private int paddingVertical; + // @since 4.3.0 + private ErrorHandler errorHandler; // @since 4.0.0 private ExecutorService executorService; - Builder(float textSize) { - this.textSize = textSize; + Builder(@NonNull JLatexMathTheme.Builder builder) { + this.theme = builder; } @NonNull - public Builder backgroundProvider(@NonNull BackgroundProvider backgroundProvider) { - this.backgroundProvider = backgroundProvider; + public JLatexMathTheme.Builder theme() { + return theme; + } + + /** + * @since 4.3.0 + */ + @NonNull + public Builder blocksEnabled(boolean blocksEnabled) { + this.blocksEnabled = blocksEnabled; + return this; + } + + /** + * @param blocksLegacy indicates if blocks should be handled in legacy mode ({@code pre 4.3.0}) + * @since 4.3.0 + */ + @NonNull + public Builder blocksLegacy(boolean blocksLegacy) { + this.blocksLegacy = blocksLegacy; + return this; + } + + /** + * @param inlinesEnabled indicates if inline parsing should be enabled. + * NB, this requires `MarkwonInlineParserPlugin` to be used when creating `MarkwonInstance` + * @since 4.3.0 + */ + @NonNull + public Builder inlinesEnabled(boolean inlinesEnabled) { + this.inlinesEnabled = inlinesEnabled; return this; } @NonNull - public Builder align(@JLatexMathDrawable.Align int align) { - this.align = align; - return this; - } - - @NonNull - public Builder fitCanvas(boolean fitCanvas) { - this.fitCanvas = fitCanvas; - return this; - } - - @NonNull - public Builder padding(@Px int padding) { - this.paddingHorizontal = padding; - this.paddingVertical = padding; - return this; - } - - /** - * @since 4.0.0 - */ - @NonNull - public Builder builder(@Px int paddingHorizontal, @Px int paddingVertical) { - this.paddingHorizontal = paddingHorizontal; - this.paddingVertical = paddingVertical; + public Builder errorHandler(@Nullable ErrorHandler errorHandler) { + this.errorHandler = errorHandler; return this; } /** * @since 4.0.0 */ + @SuppressWarnings("WeakerAccess") @NonNull public Builder executorService(@NonNull ExecutorService executorService) { this.executorService = executorService; @@ -248,7 +354,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { } // @since 4.0.0 - private static class JLatextAsyncDrawableLoader extends AsyncDrawableLoader { + static class JLatextAsyncDrawableLoader extends AsyncDrawableLoader { private final Config config; private final Handler handler = new Handler(Looper.getMainLooper()); @@ -278,46 +384,41 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { try { execute(); } catch (Throwable t) { - Log.e( - "JLatexMathPlugin", - "Error displaying latex: `" + drawable.getDestination() + "`", - t); + // @since 4.3.0 add error handling + final ErrorHandler errorHandler = config.errorHandler; + if (errorHandler == null) { + // as before + Log.e( + "JLatexMathPlugin", + "Error displaying latex: `" + drawable.getDestination() + "`", + t); + } else { + // just call `getDestination` without casts and checks + final Drawable errorDrawable = errorHandler.handleError( + drawable.getDestination(), + t + ); + if (errorDrawable != null) { + DrawableUtils.applyIntrinsicBoundsIfEmpty(errorDrawable); + setResult(drawable, errorDrawable); + } + } } } private void execute() { - // @since 4.0.1 (background provider can be null) - final BackgroundProvider backgroundProvider = config.backgroundProvider; + final JLatexMathDrawable jLatexMathDrawable; - // create JLatexMathDrawable - //noinspection ConstantConditions - final JLatexMathDrawable jLatexMathDrawable = - JLatexMathDrawable.builder(drawable.getDestination()) - .textSize(config.textSize) - .background(backgroundProvider != null ? backgroundProvider.provide() : null) - .align(config.align) - .fitCanvas(config.fitCanvas) - .padding( - config.paddingHorizontal, - config.paddingVertical, - config.paddingHorizontal, - config.paddingVertical) - .build(); + final JLatextAsyncDrawable jLatextAsyncDrawable = (JLatextAsyncDrawable) drawable; - // we must post to handler, but also have a way to identify the drawable - // for which we are posting (in case of cancellation) - handler.postAtTime(new Runnable() { - @Override - public void run() { - // remove entry from cache (it will be present if task is not cancelled) - if (cache.remove(drawable) != null - && drawable.isAttached()) { - drawable.setResult(jLatexMathDrawable); - } + if (jLatextAsyncDrawable.isBlock()) { + jLatexMathDrawable = createBlockDrawable(jLatextAsyncDrawable); + } else { + jLatexMathDrawable = createInlineDrawable(jLatextAsyncDrawable); + } - } - }, drawable, SystemClock.uptimeMillis()); + setResult(drawable, jLatexMathDrawable); } })); } @@ -342,47 +443,94 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { public Drawable placeholder(@NonNull AsyncDrawable drawable) { return null; } + + // @since 4.3.0 + @NonNull + private JLatexMathDrawable createBlockDrawable(@NonNull JLatextAsyncDrawable drawable) { + + final String latex = drawable.getDestination(); + + final JLatexMathTheme theme = config.theme; + + final JLatexMathTheme.BackgroundProvider backgroundProvider = theme.blockBackgroundProvider(); + final JLatexMathTheme.Padding padding = theme.blockPadding(); + final int color = theme.blockTextColor(); + + final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) + .textSize(theme.blockTextSize()) + .align(theme.blockHorizontalAlignment()) + .fitCanvas(theme.blockFitCanvas()); + + if (backgroundProvider != null) { + builder.background(backgroundProvider.provide()); + } + + if (padding != null) { + builder.padding(padding.left, padding.top, padding.right, padding.bottom); + } + + if (color != 0) { + builder.color(color); + } + + return builder.build(); + } + + // @since 4.3.0 + @NonNull + private JLatexMathDrawable createInlineDrawable(@NonNull JLatextAsyncDrawable drawable) { + + final String latex = drawable.getDestination(); + + final JLatexMathTheme theme = config.theme; + + final JLatexMathTheme.BackgroundProvider backgroundProvider = theme.inlineBackgroundProvider(); + final JLatexMathTheme.Padding padding = theme.inlinePadding(); + final int color = theme.inlineTextColor(); + + final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) + .textSize(theme.inlineTextSize()) + .fitCanvas(false); + + if (backgroundProvider != null) { + builder.background(backgroundProvider.provide()); + } + + if (padding != null) { + builder.padding(padding.left, padding.top, padding.right, padding.bottom); + } + + if (color != 0) { + builder.color(color); + } + + return builder.build(); + } + + // @since 4.3.0 + private void setResult(@NonNull final AsyncDrawable drawable, @NonNull final Drawable result) { + // we must post to handler, but also have a way to identify the drawable + // for which we are posting (in case of cancellation) + handler.postAtTime(new Runnable() { + @Override + public void run() { + // remove entry from cache (it will be present if task is not cancelled) + if (cache.remove(drawable) != null + && drawable.isAttached()) { + drawable.setResult(result); + } + + } + }, drawable, SystemClock.uptimeMillis()); + } } - // we must make drawable fit canvas (if specified), but do not keep the ratio whilst scaling up - // @since 4.0.0 - private static class JLatexImageSizeResolver extends ImageSizeResolver { - - private final boolean fitCanvas; - - JLatexImageSizeResolver(boolean fitCanvas) { - this.fitCanvas = fitCanvas; - } + private static class InlineImageSizeResolver extends ImageSizeResolver { @NonNull @Override public Rect resolveImageSize(@NonNull AsyncDrawable drawable) { - - final Rect imageBounds = drawable.getResult().getBounds(); - final int canvasWidth = drawable.getLastKnownCanvasWidth(); - - if (fitCanvas) { - - // we modify bounds only if `fitCanvas` is true - final int w = imageBounds.width(); - - if (w < canvasWidth) { - // increase width and center formula (keep height as-is) - return new Rect(0, 0, canvasWidth, imageBounds.height()); - } - - // @since 4.0.2 we additionally scale down the resulting formula (keeping the ratio) - // the thing is - JLatexMathDrawable will do it anyway, but it will modify its own - // bounds (which AsyncDrawable won't catch), thus leading to an empty space after the formula - 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; + return drawable.getResult().getBounds(); } } } diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathTheme.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathTheme.java new file mode 100644 index 00000000..d882e9c1 --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatexMathTheme.java @@ -0,0 +1,351 @@ +package io.noties.markwon.ext.latex; + +import android.graphics.drawable.Drawable; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; + +import ru.noties.jlatexmath.JLatexMathDrawable; + +/** + * @since 4.3.0 + */ +public abstract class JLatexMathTheme { + + @NonNull + public static JLatexMathTheme create(@Px float textSize) { + return builder(textSize).build(); + } + + @NonNull + public static JLatexMathTheme create(@Px float inlineTextSize, @Px float blockTextSize) { + return builder(inlineTextSize, blockTextSize).build(); + } + + @NonNull + public static JLatexMathTheme.Builder builder(@Px float textSize) { + return new JLatexMathTheme.Builder(textSize, 0F, 0F); + } + + @NonNull + public static JLatexMathTheme.Builder builder(@Px float inlineTextSize, @Px float blockTextSize) { + return new Builder(0F, inlineTextSize, blockTextSize); + } + + /** + * Moved from {@link JLatexMathPlugin} in {@code 4.3.0} version + * + * @since 4.0.0 + */ + public interface BackgroundProvider { + @NonNull + Drawable provide(); + } + + /** + * Special immutable class to hold padding information + */ + @SuppressWarnings("WeakerAccess") + public static class Padding { + public final int left; + public final int top; + public final int right; + public final int bottom; + + public Padding(int left, int top, int right, int bottom) { + this.left = left; + this.top = top; + this.right = right; + this.bottom = bottom; + } + + @NonNull + @Override + public String toString() { + return "Padding{" + + "left=" + left + + ", top=" + top + + ", right=" + right + + ", bottom=" + bottom + + '}'; + } + + @NonNull + public static Padding all(int value) { + return new Padding(value, value, value, value); + } + + @NonNull + public static Padding symmetric(int vertical, int horizontal) { + return new Padding(horizontal, vertical, horizontal, vertical); + } + } + + /** + * @return text size in pixels for inline LaTeX + * @see #blockTextSize() + */ + @Px + public abstract float inlineTextSize(); + + /** + * @return text size in pixels for block LaTeX + * @see #inlineTextSize() + */ + @Px + public abstract float blockTextSize(); + + @Nullable + public abstract BackgroundProvider inlineBackgroundProvider(); + + @Nullable + public abstract BackgroundProvider blockBackgroundProvider(); + + /** + * @return boolean if block LaTeX must fit the width of canvas + */ + public abstract boolean blockFitCanvas(); + + /** + * @return horizontal alignment of block LaTeX if {@link #blockFitCanvas()} + * is enabled (thus space for alignment is available) + */ + @JLatexMathDrawable.Align + public abstract int blockHorizontalAlignment(); + + @Nullable + public abstract Padding inlinePadding(); + + @Nullable + public abstract Padding blockPadding(); + + @ColorInt + public abstract int inlineTextColor(); + + @ColorInt + public abstract int blockTextColor(); + + @SuppressWarnings({"unused", "UnusedReturnValue"}) + public static class Builder { + private final float textSize; + private final float inlineTextSize; + private final float blockTextSize; + + private BackgroundProvider backgroundProvider; + private BackgroundProvider inlineBackgroundProvider; + private BackgroundProvider blockBackgroundProvider; + + private boolean blockFitCanvas = true; + // horizontal alignment (when there is additional horizontal space) + private int blockHorizontalAlignment = JLatexMathDrawable.ALIGN_CENTER; + + private Padding padding; + private Padding inlinePadding; + private Padding blockPadding; + + private int textColor; + private int inlineTextColor; + private int blockTextColor; + + Builder(float textSize, float inlineTextSize, float blockTextSize) { + this.textSize = textSize; + this.inlineTextSize = inlineTextSize; + this.blockTextSize = blockTextSize; + } + + @NonNull + public Builder backgroundProvider(@Nullable BackgroundProvider backgroundProvider) { + this.backgroundProvider = backgroundProvider; + this.inlineBackgroundProvider = backgroundProvider; + this.blockBackgroundProvider = backgroundProvider; + return this; + } + + @NonNull + public Builder inlineBackgroundProvider(@Nullable BackgroundProvider inlineBackgroundProvider) { + this.inlineBackgroundProvider = inlineBackgroundProvider; + return this; + } + + @NonNull + public Builder blockBackgroundProvider(@Nullable BackgroundProvider blockBackgroundProvider) { + this.blockBackgroundProvider = blockBackgroundProvider; + return this; + } + + @NonNull + public Builder blockFitCanvas(boolean blockFitCanvas) { + this.blockFitCanvas = blockFitCanvas; + return this; + } + + @NonNull + public Builder blockHorizontalAlignment(@JLatexMathDrawable.Align int blockHorizontalAlignment) { + this.blockHorizontalAlignment = blockHorizontalAlignment; + return this; + } + + @NonNull + public Builder padding(@Nullable Padding padding) { + this.padding = padding; + this.inlinePadding = padding; + this.blockPadding = padding; + return this; + } + + @NonNull + public Builder inlinePadding(@Nullable Padding inlinePadding) { + this.inlinePadding = inlinePadding; + return this; + } + + @NonNull + public Builder blockPadding(@Nullable Padding blockPadding) { + this.blockPadding = blockPadding; + return this; + } + + @NonNull + public Builder textColor(@ColorInt int textColor) { + this.textColor = textColor; + return this; + } + + @NonNull + public Builder inlineTextColor(@ColorInt int inlineTextColor) { + this.inlineTextColor = inlineTextColor; + return this; + } + + @NonNull + public Builder blockTextColor(@ColorInt int blockTextColor) { + this.blockTextColor = blockTextColor; + return this; + } + + @NonNull + public JLatexMathTheme build() { + return new Impl(this); + } + } + + static class Impl extends JLatexMathTheme { + + private final float textSize; + private final float inlineTextSize; + private final float blockTextSize; + + private final BackgroundProvider backgroundProvider; + private final BackgroundProvider inlineBackgroundProvider; + private final BackgroundProvider blockBackgroundProvider; + + private final boolean blockFitCanvas; + // horizontal alignment (when there is additional horizontal space) + private int blockHorizontalAlignment; + + private final Padding padding; + private final Padding inlinePadding; + private final Padding blockPadding; + + private final int textColor; + private final int inlineTextColor; + private final int blockTextColor; + + Impl(@NonNull Builder builder) { + this.textSize = builder.textSize; + this.inlineTextSize = builder.inlineTextSize; + this.blockTextSize = builder.blockTextSize; + this.backgroundProvider = builder.backgroundProvider; + this.inlineBackgroundProvider = builder.inlineBackgroundProvider; + this.blockBackgroundProvider = builder.blockBackgroundProvider; + this.blockFitCanvas = builder.blockFitCanvas; + this.blockHorizontalAlignment = builder.blockHorizontalAlignment; + this.padding = builder.padding; + this.inlinePadding = builder.inlinePadding; + this.blockPadding = builder.blockPadding; + this.textColor = builder.textColor; + this.inlineTextColor = builder.inlineTextColor; + this.blockTextColor = builder.blockTextColor; + } + + @Override + public float inlineTextSize() { + if (inlineTextSize > 0F) { + return inlineTextSize; + } + return textSize; + } + + @Override + public float blockTextSize() { + if (blockTextSize > 0F) { + return blockTextSize; + } + return textSize; + } + + @Nullable + @Override + public BackgroundProvider inlineBackgroundProvider() { + if (inlineBackgroundProvider != null) { + return inlineBackgroundProvider; + } + return backgroundProvider; + } + + @Nullable + @Override + public BackgroundProvider blockBackgroundProvider() { + if (blockBackgroundProvider != null) { + return blockBackgroundProvider; + } + return backgroundProvider; + } + + @Override + public boolean blockFitCanvas() { + return blockFitCanvas; + } + + @Override + public int blockHorizontalAlignment() { + return blockHorizontalAlignment; + } + + @Nullable + @Override + public Padding inlinePadding() { + if (inlinePadding != null) { + return inlinePadding; + } + return padding; + } + + @Nullable + @Override + public Padding blockPadding() { + if (blockPadding != null) { + return blockPadding; + } + return padding; + } + + @Override + public int inlineTextColor() { + if (inlineTextColor != 0) { + return inlineTextColor; + } + return textColor; + } + + @Override + public int blockTextColor() { + if (blockTextColor != 0) { + return blockTextColor; + } + return textColor; + } + } +} diff --git a/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatextAsyncDrawable.java b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatextAsyncDrawable.java new file mode 100644 index 00000000..25c67262 --- /dev/null +++ b/markwon-ext-latex/src/main/java/io/noties/markwon/ext/latex/JLatextAsyncDrawable.java @@ -0,0 +1,32 @@ +package io.noties.markwon.ext.latex; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.AsyncDrawableLoader; +import io.noties.markwon.image.ImageSize; +import io.noties.markwon.image.ImageSizeResolver; + +/** + * @since 4.3.0 + */ +class JLatextAsyncDrawable extends AsyncDrawable { + + private final boolean isBlock; + + JLatextAsyncDrawable( + @NonNull String destination, + @NonNull AsyncDrawableLoader loader, + @NonNull ImageSizeResolver imageSizeResolver, + @Nullable ImageSize imageSize, + boolean isBlock + ) { + super(destination, loader, imageSizeResolver, imageSize); + this.isBlock = isBlock; + } + + public boolean isBlock() { + return isBlock; + } +} diff --git a/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathBlockParserTest.java b/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathBlockParserTest.java new file mode 100644 index 00000000..b1587e1d --- /dev/null +++ b/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathBlockParserTest.java @@ -0,0 +1,173 @@ +package io.noties.markwon.ext.latex; + +import androidx.annotation.NonNull; + +import org.commonmark.internal.BlockContinueImpl; +import org.commonmark.internal.BlockStartImpl; +import org.commonmark.internal.util.Parsing; +import org.commonmark.parser.block.BlockStart; +import org.commonmark.parser.block.ParserState; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class JLatexMathBlockParserTest { + + private static final String[] NO_MATCH = { + " ", + " ", + " ", + "$ ", + " $ $", + "-$$", + " -$$", + "$$-", + " $$ -", + " $$ -", + "$$$ -" + }; + + private static final String[] MATCH = { + "$$", + " $$", + " $$", + " $$", + "$$ ", + " $$ ", + " $$ ", + " $$ ", + "$$$", + " $$$", + " $$$", + "$$$$", + " $$$$", + "$$$$$$$$$$$$$$$$$$$$$", + " $$$$$$$$$$$$$$$$$$$$$", + " $$$$$$$$$$$$$$$$$$$$$", + " $$$$$$$$$$$$$$$$$$$$$" + }; + + private JLatexMathBlockParser.Factory factory; + + @Before + public void before() { + factory = new JLatexMathBlockParser.Factory(); + } + + @Test + public void factory_indentBlock() { + // when state indent is greater than block -> nono + + final ParserState state = mock(ParserState.class); + when(state.getIndent()).thenReturn(Parsing.CODE_BLOCK_INDENT); + + // hm, interesting, `BlockStart.none()` actually returns null + final BlockStart start = factory.tryStart(state, null); + assertNull(start); + } + + @Test + public void factory_noMatch() { + + for (String line : NO_MATCH) { + final ParserState state = createState(line); + + assertNull(factory.tryStart(state, null)); + } + } + + @Test + public void factory_match() { + + for (String line : MATCH) { + final ParserState state = createState(line); + + final BlockStart start = factory.tryStart(state, null); + assertNotNull(start); + + // hm... + final BlockStartImpl impl = (BlockStartImpl) start; + assertEquals(quote(line), line.length() + 1, impl.getNewIndex()); + } + } + + @Test + public void finish() { + + for (String line : MATCH) { + final ParserState state = createState(line); + + // we will have 2 checks here: + // * must pass for correct length + // * must fail for incorrect + + final int count = countDollarSigns(line); + + // pass + { + final JLatexMathBlockParser parser = new JLatexMathBlockParser(count); + final BlockContinueImpl impl = (BlockContinueImpl) parser.tryContinue(state); + assertTrue(quote(line), impl.isFinalize()); + } + + // fail (in terms of closing, not failing test) + { + final JLatexMathBlockParser parser = new JLatexMathBlockParser(count + 1); + final BlockContinueImpl impl = (BlockContinueImpl) parser.tryContinue(state); + assertFalse(quote(line), impl.isFinalize()); + } + } + } + + @Test + public void finish_noMatch() { + for (String line : NO_MATCH) { + final ParserState state = createState(line); + // doesn't matter + final int count = 2; + final JLatexMathBlockParser parser = new JLatexMathBlockParser(count); + final BlockContinueImpl impl = (BlockContinueImpl) parser.tryContinue(state); + assertFalse(quote(line), impl.isFinalize()); + } + } + + @NonNull + private static ParserState createState(@NonNull String line) { + + final ParserState state = mock(ParserState.class); + + int i = 0; + for (int length = line.length(); i < length; i++) { + if (' ' != line.charAt(i)) { + // previous is the last space + break; + } + } + + when(state.getIndent()).thenReturn(i); + when(state.getNextNonSpaceIndex()).thenReturn(i); + when(state.getLine()).thenReturn(line); + + return state; + } + + private static int countDollarSigns(@NonNull String line) { + int count = 0; + for (int i = 0, length = line.length(); i < length; i++) { + if ('$' == line.charAt(i)) count += 1; + } + return count; + } + + @NonNull + private static String quote(@NonNull String s) { + return '\'' + s + '\''; + } +} \ No newline at end of file diff --git a/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathPluginTest.java b/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathPluginTest.java index 8190a470..a2467ae3 100644 --- a/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathPluginTest.java +++ b/markwon-ext-latex/src/test/java/io/noties/markwon/ext/latex/JLatexMathPluginTest.java @@ -10,17 +10,24 @@ import org.mockito.ArgumentCaptor; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import java.util.List; import java.util.concurrent.ExecutorService; import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.MarkwonPlugin; import io.noties.markwon.MarkwonVisitor; import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.inlineparser.InlineProcessor; +import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -110,4 +117,114 @@ public class JLatexMathPluginTest { verify(visitor, times(1)).setSpans(eq(0), any()); } + + @Test + public void legacy() { + // if render mode is legacy: + // - no inline plugin is required, + // - parser has legacy block parser factory + // - no inline node is registered (node) + + final JLatexMathPlugin plugin = JLatexMathPlugin.create(1, new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + builder.blocksLegacy(true); + builder.inlinesEnabled(false); + } + }); + + // registry + { + final MarkwonPlugin.Registry registry = mock(MarkwonPlugin.Registry.class); + plugin.configure(registry); + verify(registry, never()).require(any(Class.class)); + } + + // parser + { + final Parser.Builder builder = mock(Parser.Builder.class); + plugin.configureParser(builder); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(BlockParserFactory.class); + verify(builder, times(1)).customBlockParserFactory(captor.capture()); + final BlockParserFactory factory = captor.getValue(); + assertTrue(factory.getClass().getName(), factory instanceof JLatexMathBlockParserLegacy.Factory); + } + + // visitor + { + final MarkwonVisitor.Builder builder = mock(MarkwonVisitor.Builder.class); + plugin.configureVisitor(builder); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); + verify(builder, times(1)).on(captor.capture(), any(MarkwonVisitor.NodeVisitor.class)); + + assertEquals(JLatexMathBlock.class, captor.getValue()); + } + } + + @Test + public void blocks_inlines_implicit() { + final JLatexMathPlugin plugin = JLatexMathPlugin.create(1); + final JLatexMathPlugin.Config config = plugin.config; + assertTrue("blocksEnabled", config.blocksEnabled); + assertFalse("blocksLegacy", config.blocksLegacy); + assertFalse("inlinesEnabled", config.inlinesEnabled); + } + + @Test + public void blocks_inlines() { + final JLatexMathPlugin plugin = JLatexMathPlugin.create(12, new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + builder.inlinesEnabled(true); + } + }); + + // registry + { + final MarkwonInlineParser.FactoryBuilder factoryBuilder = mock(MarkwonInlineParser.FactoryBuilder.class); + final MarkwonInlineParserPlugin inlineParserPlugin = mock(MarkwonInlineParserPlugin.class); + final MarkwonPlugin.Registry registry = mock(MarkwonPlugin.Registry.class); + when(inlineParserPlugin.factoryBuilder()).thenReturn(factoryBuilder); + when(registry.require(eq(MarkwonInlineParserPlugin.class))).thenReturn(inlineParserPlugin); + plugin.configure(registry); + + verify(registry, times(1)).require(eq(MarkwonInlineParserPlugin.class)); + verify(inlineParserPlugin, times(1)).factoryBuilder(); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(InlineProcessor.class); + verify(factoryBuilder, times(1)).addInlineProcessor(captor.capture()); + + final InlineProcessor inlineProcessor = captor.getValue(); + assertTrue(inlineParserPlugin.getClass().getName(), inlineProcessor instanceof JLatexMathInlineProcessor); + } + + // parser + { + final Parser.Builder builder = mock(Parser.Builder.class); + plugin.configureParser(builder); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(BlockParserFactory.class); + verify(builder, times(1)).customBlockParserFactory(captor.capture()); + final BlockParserFactory factory = captor.getValue(); + assertTrue(factory.getClass().getName(), factory instanceof JLatexMathBlockParser.Factory); + } + + // visitor + { + final MarkwonVisitor.Builder builder = mock(MarkwonVisitor.Builder.class); + plugin.configureVisitor(builder); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); + verify(builder, times(2)).on(captor.capture(), any(MarkwonVisitor.NodeVisitor.class)); + + final List nodes = captor.getAllValues(); + assertEquals(2, nodes.size()); + assertTrue(nodes.toString(), nodes.contains(JLatexMathNode.class)); + assertTrue(nodes.toString(), nodes.contains(JLatexMathBlock.class)); + } + } } \ No newline at end of file diff --git a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TablePlugin.java b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TablePlugin.java index 086afd75..0cbce8b8 100644 --- a/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TablePlugin.java +++ b/markwon-ext-tables/src/main/java/io/noties/markwon/ext/tables/TablePlugin.java @@ -121,12 +121,15 @@ public class TablePlugin extends AbstractMarkwonPlugin { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull TableBlock tableBlock) { + visitor.blockStart(tableBlock); + visitor.visitChildren(tableBlock); - if (visitor.hasNext(tableBlock)) { - visitor.ensureNewLine(); - visitor.forceNewLine(); - } +// if (visitor.hasNext(tableBlock)) { +// visitor.ensureNewLine(); +// visitor.forceNewLine(); +// } + visitor.blockEnd(tableBlock); } }) .on(TableBody.class, new MarkwonVisitor.NodeVisitor() { diff --git a/markwon-inline-parser/build.gradle b/markwon-inline-parser/build.gradle index 703a18ff..32a45d7c 100644 --- a/markwon-inline-parser/build.gradle +++ b/markwon-inline-parser/build.gradle @@ -14,6 +14,7 @@ android { } dependencies { + api project(':markwon-core') api deps['x-annotations'] api deps['commonmark'] diff --git a/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserPlugin.java b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserPlugin.java new file mode 100644 index 00000000..470e2fb8 --- /dev/null +++ b/markwon-inline-parser/src/main/java/io/noties/markwon/inlineparser/MarkwonInlineParserPlugin.java @@ -0,0 +1,59 @@ +package io.noties.markwon.inlineparser; + +import androidx.annotation.NonNull; + +import org.commonmark.parser.Parser; + +import io.noties.markwon.AbstractMarkwonPlugin; + +/** + * @since 4.3.0 + */ +public class MarkwonInlineParserPlugin extends AbstractMarkwonPlugin { + + public interface BuilderConfigure { + void configureBuilder(@NonNull B factoryBuilder); + } + + @NonNull + public static MarkwonInlineParserPlugin create() { + return create(MarkwonInlineParser.factoryBuilder()); + } + + @NonNull + public static MarkwonInlineParserPlugin create(@NonNull BuilderConfigure configure) { + final MarkwonInlineParser.FactoryBuilder factoryBuilder = MarkwonInlineParser.factoryBuilder(); + configure.configureBuilder(factoryBuilder); + return new MarkwonInlineParserPlugin(factoryBuilder); + } + + @NonNull + public static MarkwonInlineParserPlugin create(@NonNull MarkwonInlineParser.FactoryBuilder factoryBuilder) { + return new MarkwonInlineParserPlugin(factoryBuilder); + } + + @NonNull + public static MarkwonInlineParserPlugin create( + @NonNull B factoryBuilder, + @NonNull BuilderConfigure configure) { + configure.configureBuilder(factoryBuilder); + return new MarkwonInlineParserPlugin(factoryBuilder); + } + + private final MarkwonInlineParser.FactoryBuilder factoryBuilder; + + @SuppressWarnings("WeakerAccess") + MarkwonInlineParserPlugin(@NonNull MarkwonInlineParser.FactoryBuilder factoryBuilder) { + this.factoryBuilder = factoryBuilder; + } + + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.inlineParserFactory(factoryBuilder.build()); + } + + @NonNull + public MarkwonInlineParser.FactoryBuilder factoryBuilder() { + return factoryBuilder; + } +} diff --git a/markwon-linkify/build.gradle b/markwon-linkify/build.gradle index 764113ab..39f8eecf 100644 --- a/markwon-linkify/build.gradle +++ b/markwon-linkify/build.gradle @@ -14,7 +14,13 @@ android { } dependencies { + deps.with { + // To use LinkifyCompat + // note that this dependency must be added on a client side explicitly + compileOnly it['x-core'] + } + api project(':markwon-core') } -registerArtifact(this) \ No newline at end of file +registerArtifact(this) diff --git a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java index cb5c889e..ded176f6 100644 --- a/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java +++ b/markwon-linkify/src/main/java/io/noties/markwon/linkify/LinkifyPlugin.java @@ -1,11 +1,13 @@ package io.noties.markwon.linkify; +import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.style.URLSpan; import android.text.util.Linkify; import androidx.annotation.IntDef; import androidx.annotation.NonNull; +import androidx.core.text.util.LinkifyCompat; import org.commonmark.node.Link; @@ -33,19 +35,43 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { @NonNull public static LinkifyPlugin create() { - return create(Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS | Linkify.WEB_URLS); + return create(false); + } + + /** + * @param useCompat If true, use {@link LinkifyCompat} to handle links. + * Note that the {@link LinkifyCompat} depends on androidx.core:core, + * the dependency must be added on a client side explicitly. + * @since 4.3.0 `useCompat` argument + */ + @NonNull + public static LinkifyPlugin create(boolean useCompat) { + return create(Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS | Linkify.WEB_URLS, useCompat); } @NonNull public static LinkifyPlugin create(@LinkifyMask int mask) { - return new LinkifyPlugin(mask); + return new LinkifyPlugin(mask, false); + } + + /** + * @param useCompat If true, use {@link LinkifyCompat} to handle links. + * Note that the {@link LinkifyCompat} depends on androidx.core:core, + * the dependency must be added on a client side explicitly. + * @since 4.3.0 `useCompat` argument + */ + @NonNull + public static LinkifyPlugin create(@LinkifyMask int mask, boolean useCompat) { + return new LinkifyPlugin(mask, useCompat); } private final int mask; + private final boolean useCompat; @SuppressWarnings("WeakerAccess") - LinkifyPlugin(@LinkifyMask int mask) { + LinkifyPlugin(@LinkifyMask int mask, boolean useCompat) { this.mask = mask; + this.useCompat = useCompat; } @Override @@ -53,7 +79,14 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { registry.require(CorePlugin.class, new Action() { @Override public void apply(@NonNull CorePlugin corePlugin) { - corePlugin.addOnTextAddedListener(new LinkifyTextAddedListener(mask)); + final LinkifyTextAddedListener listener; + // @since 4.3.0 + if (useCompat) { + listener = new LinkifyCompatTextAddedListener(mask); + } else { + listener = new LinkifyTextAddedListener(mask); + } + corePlugin.addOnTextAddedListener(listener); } }); } @@ -80,7 +113,7 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { // render calls from different threads and ... better performance) final SpannableStringBuilder builder = new SpannableStringBuilder(text); - if (Linkify.addLinks(builder, mask)) { + if (addLinks(builder, mask)) { // target URL span specifically final URLSpan[] spans = builder.getSpans(0, builder.length(), URLSpan.class); if (spans != null @@ -101,5 +134,22 @@ public class LinkifyPlugin extends AbstractMarkwonPlugin { } } } + + protected boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) { + return Linkify.addLinks(text, mask); + } + } + + // @since 4.3.0 + private static class LinkifyCompatTextAddedListener extends LinkifyTextAddedListener { + + LinkifyCompatTextAddedListener(int mask) { + super(mask); + } + + @Override + protected boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) { + return LinkifyCompat.addLinks(text, mask); + } } } diff --git a/release-management.md b/release-management.md index 36efc91f..ce66bb97 100644 --- a/release-management.md +++ b/release-management.md @@ -24,4 +24,25 @@ The issuer branch (with version name) should be deleted. A new version must be pushed to MavenCentral and new git-tag with version name must be created in the repository. -Rinse and repeat. \ No newline at end of file +Rinse and repeat. + +## `@since` annotation + +Although it is not required it is a nice thing to do: add `@since $VERSION` comment to the code +whenever it is possible (at least for publicly accessible code - API). This would help +navigating the project without the need to checkout the full VCS history. As keeping track of +current and/or upcoming version can be error-prone it is better to insert a generic `@since code` +that can be properly substituted upon a release. + +For example, `@since $nap` seems like a good candidate. For this a live template can be created and used +whenever a new API method/field/functionality-change is introduced (`snc`): + +``` +@since $nap; +``` + +This live template would be possible to use in both inline comment and javadoc comment. + +## documentation + +If there are updates to documentation web site, do not forget to publish it \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index d2a9e27f..595fd54b 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -49,6 +49,7 @@ dependencies { implementation project(':markwon-syntax-highlight') implementation project(':markwon-image-picasso') + implementation project(':markwon-image-glide') deps.with { implementation it['x-recycler-view'] diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 5e0ae714..f85d8750 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -35,6 +35,9 @@ + + + diff --git a/sample/src/main/java/io/noties/markwon/sample/ActivityWithMenuOptions.java b/sample/src/main/java/io/noties/markwon/sample/ActivityWithMenuOptions.java new file mode 100644 index 00000000..54f5342f --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/ActivityWithMenuOptions.java @@ -0,0 +1,49 @@ +package io.noties.markwon.sample; + +import android.app.Activity; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public abstract class ActivityWithMenuOptions extends Activity { + + @NonNull + public abstract MenuOptions menuOptions(); + + protected void beforeOptionSelected(@NonNull String option) { + // no op, override to customize + } + + protected void afterOptionSelected(@NonNull String option) { + // no op, override to customize + } + + private MenuOptions menuOptions; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + menuOptions = menuOptions(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + return menuOptions.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final MenuOptions.Option option = menuOptions.onOptionsItemSelected(item); + if (option != null) { + beforeOptionSelected(option.title); + option.action.run(); + afterOptionSelected(option.title); + return true; + } + return false; + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java index 4cdd6d73..2ac55d99 100644 --- a/sample/src/main/java/io/noties/markwon/sample/MainActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/MainActivity.java @@ -25,11 +25,14 @@ import io.noties.markwon.sample.customextension2.CustomExtensionActivity2; import io.noties.markwon.sample.editor.EditorActivity; import io.noties.markwon.sample.html.HtmlActivity; import io.noties.markwon.sample.htmldetails.HtmlDetailsActivity; +import io.noties.markwon.sample.images.ImagesActivity; import io.noties.markwon.sample.inlineparser.InlineParserActivity; import io.noties.markwon.sample.latex.LatexActivity; +import io.noties.markwon.sample.notification.NotificationActivity; import io.noties.markwon.sample.precomputed.PrecomputedActivity; import io.noties.markwon.sample.recycler.RecyclerActivity; import io.noties.markwon.sample.simpleext.SimpleExtActivity; +import io.noties.markwon.sample.tasklist.TaskListActivity; public class MainActivity extends Activity { @@ -132,6 +135,18 @@ public class MainActivity extends Activity { activity = HtmlDetailsActivity.class; break; + case TASK_LIST: + activity = TaskListActivity.class; + break; + + case IMAGES: + activity = ImagesActivity.class; + break; + + case REMOTE_VIEWS: + activity = NotificationActivity.class; + break; + default: throw new IllegalStateException("No Activity is associated with sample-item: " + item); } diff --git a/sample/src/main/java/io/noties/markwon/sample/MenuOptions.java b/sample/src/main/java/io/noties/markwon/sample/MenuOptions.java new file mode 100644 index 00000000..6fb5b310 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/MenuOptions.java @@ -0,0 +1,57 @@ +package io.noties.markwon.sample; + +import android.view.Menu; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class MenuOptions { + + @NonNull + public static MenuOptions create() { + return new MenuOptions(); + } + + static class Option { + final String title; + final Runnable action; + + Option(@NonNull String title, @NonNull Runnable action) { + this.title = title; + this.action = action; + } + } + + // to preserve order use LinkedHashMap + private final Map actions = new LinkedHashMap<>(); + + @NonNull + public MenuOptions add(@NonNull String title, @NonNull Runnable action) { + actions.put(title, action); + return this; + } + + boolean onCreateOptionsMenu(Menu menu) { + if (!actions.isEmpty()) { + for (String key : actions.keySet()) { + menu.add(key); + } + return true; + } + return false; + } + + @Nullable + Option onOptionsItemSelected(MenuItem item) { + final String title = String.valueOf(item.getTitle()); + final Runnable action = actions.get(title); + if (action != null) { + return new Option(title, action); + } + return null; + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/Sample.java b/sample/src/main/java/io/noties/markwon/sample/Sample.java index 36b13cd2..f18ed25b 100644 --- a/sample/src/main/java/io/noties/markwon/sample/Sample.java +++ b/sample/src/main/java/io/noties/markwon/sample/Sample.java @@ -27,7 +27,13 @@ public enum Sample { INLINE_PARSER(R.string.sample_inline_parser), - HTML_DETAILS(R.string.sample_html_details); + HTML_DETAILS(R.string.sample_html_details), + + TASK_LIST(R.string.sample_task_list), + + IMAGES(R.string.sample_images), + + REMOTE_VIEWS(R.string.sample_remote_views); private final int textResId; diff --git a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java index df22cf06..e8bb4761 100644 --- a/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/basicplugins/BasicPluginsActivity.java @@ -1,77 +1,94 @@ package io.noties.markwon.sample.basicplugins; -import android.app.Activity; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; -import android.text.Layout; +import android.text.Spannable; +import android.text.Spanned; import android.text.TextUtils; -import android.text.style.AlignmentSpan; import android.text.style.ForegroundColorSpan; +import android.view.View; +import android.widget.ScrollView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.commonmark.node.Heading; +import org.commonmark.node.Node; import org.commonmark.node.Paragraph; import java.util.Collection; import java.util.Collections; import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.BlockHandlerDef; +import io.noties.markwon.LinkResolverDef; import io.noties.markwon.Markwon; import io.noties.markwon.MarkwonConfiguration; -import io.noties.markwon.MarkwonPlugin; import io.noties.markwon.MarkwonSpansFactory; import io.noties.markwon.MarkwonVisitor; -import io.noties.markwon.RenderProps; +import io.noties.markwon.SoftBreakAddsNewLinePlugin; +import io.noties.markwon.core.CoreProps; import io.noties.markwon.core.MarkwonTheme; -import io.noties.markwon.html.HtmlPlugin; -import io.noties.markwon.html.HtmlTag; -import io.noties.markwon.html.tag.SimpleTagHandler; +import io.noties.markwon.core.spans.HeadingSpan; +import io.noties.markwon.core.spans.LastLineSpacingSpan; import io.noties.markwon.image.ImageItem; import io.noties.markwon.image.ImagesPlugin; import io.noties.markwon.image.SchemeHandler; import io.noties.markwon.image.network.NetworkSchemeHandler; import io.noties.markwon.movement.MovementMethodPlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; +import io.noties.markwon.sample.R; -public class BasicPluginsActivity extends Activity { +public class BasicPluginsActivity extends ActivityWithMenuOptions { private TextView textView; + private ScrollView scrollView; + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("paragraphSpan", this::paragraphSpan) + .add("disableNode", this::disableNode) + .add("customizeTheme", this::customizeTheme) + .add("linkWithMovementMethod", this::linkWithMovementMethod) + .add("imagesPlugin", this::imagesPlugin) + .add("softBreakAddsSpace", this::softBreakAddsSpace) + .add("softBreakAddsNewLine", this::softBreakAddsNewLine) + .add("additionalSpacing", this::additionalSpacing) + .add("headingNoSpace", this::headingNoSpace) + .add("headingNoSpaceBlockHandler", this::headingNoSpaceBlockHandler) + .add("allBlocksNoForcedLine", this::allBlocksNoForcedLine) + .add("anchor", this::anchor); + } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setContentView(R.layout.activity_text_view); - textView = new TextView(this); - setContentView(textView); + textView = findViewById(R.id.text_view); + scrollView = findViewById(R.id.scroll_view); - step_1(); - - step_2(); - - step_3(); - - step_4(); - - step_5(); - - step_6(); + paragraphSpan(); +// +// disableNode(); +// +// customizeTheme(); +// +// linkWithMovementMethod(); +// +// imagesPlugin(); } /** * In order to apply paragraph spans a custom plugin should be created (CorePlugin will take care * of everything else). - *

- * Please note that when a plugin is registered and it depends on CorePlugin, there is no - * need to explicitly specify it. By default all plugins that extend AbstractMarkwonPlugin do declare - * it\'s dependency on CorePlugin ({@link MarkwonPlugin#priority()}). - *

- * Order in which plugins are specified to the builder is of little importance as long as each - * plugin clearly states what dependencies it has */ - private void step_1() { + private void paragraphSpan() { final String markdown = "# Hello!\n\nA paragraph?\n\nIt should be!"; @@ -91,7 +108,7 @@ public class BasicPluginsActivity extends Activity { /** * To disable some nodes from rendering another custom plugin can be used */ - private void step_2() { + private void disableNode() { final String markdown = "# Heading 1\n\n## Heading 2\n\n**other** content [here](#)"; @@ -116,7 +133,7 @@ public class BasicPluginsActivity extends Activity { /** * To customize core theme plugin can be used again */ - private void step_3() { + private void customizeTheme() { final String markdown = "`A code` that is rendered differently\n\n```\nHello!\n```"; @@ -145,7 +162,7 @@ public class BasicPluginsActivity extends Activity { *

* In order to customize them a custom plugin should be used */ - private void step_4() { + private void linkWithMovementMethod() { final String markdown = "[a link without scheme](github.com)"; @@ -178,7 +195,7 @@ public class BasicPluginsActivity extends Activity { * images handling (parsing markdown containing images, obtain an image from network * file system or assets). Please note that */ - private void step_5() { + private void imagesPlugin() { final String markdown = "![image](myownscheme://en.wikipedia.org/static/images/project-logos/enwiki-2x.png)"; @@ -220,33 +237,269 @@ public class BasicPluginsActivity extends Activity { markwon.setMarkdown(textView, markdown); } - public void step_6() { + private void softBreakAddsSpace() { + // default behavior + + final String md = "" + + "Hello there ->(line)\n(break)<- going on and on"; + + Markwon.create(this).setMarkdown(textView, md); + } + + private void softBreakAddsNewLine() { + // insert a new line when markdown has a soft break + + final Markwon markwon = Markwon.builder(this) + .usePlugin(SoftBreakAddsNewLinePlugin.create()) + .build(); + + final String md = "" + + "Hello there ->(line)\n(break)<- going on and on"; + + markwon.setMarkdown(textView, md); + } + + private void additionalSpacing() { + + // please note that bottom line (after 1 & 2 levels) will be drawn _AFTER_ padding + final int spacing = (int) (128 * getResources().getDisplayMetrics().density + .5F); final Markwon markwon = Markwon.builder(this) - .usePlugin(HtmlPlugin.create()) .usePlugin(new AbstractMarkwonPlugin() { @Override - public void configure(@NonNull Registry registry) { - registry.require(HtmlPlugin.class, plugin -> plugin.addHandler(new SimpleTagHandler() { - @Override - public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { - return new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER); - } + public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.headingBreakHeight(0); + } - @NonNull - @Override - public Collection supportedTags() { - return Collections.singleton("center"); - } - })); + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.appendFactory( + Heading.class, + (configuration, props) -> new LastLineSpacingSpan(spacing)); } }) .build(); + + final String md = "" + + "# Title title title title title title title title title title \n\ntext text text text"; + + markwon.setMarkdown(textView, md); } + private void headingNoSpace() { + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.headingBreakHeight(0); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(Heading.class, (visitor, heading) -> { + + visitor.ensureNewLine(); + + final int length = visitor.length(); + visitor.visitChildren(heading); + + CoreProps.HEADING_LEVEL.set(visitor.renderProps(), heading.getLevel()); + + visitor.setSpansForNodeOptional(heading, length); + + if (visitor.hasNext(heading)) { + visitor.ensureNewLine(); +// visitor.forceNewLine(); + } + }); + } + }) + .build(); + + final String md = "" + + "# Title title title title title title title title title title \n\ntext text text text"; + + markwon.setMarkdown(textView, md); + } + + private void headingNoSpaceBlockHandler() { +final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(new BlockHandlerDef() { + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (node instanceof Heading) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + // ensure new line but do not force insert one + } + } else { + super.blockEnd(visitor, node); + } + } + }); + } + }) + .build(); + + final String md = "" + + "# Title title title title title title title title title title \n\ntext text text text"; + + markwon.setMarkdown(textView, md); + } + + private void allBlocksNoForcedLine() { + final MarkwonVisitor.BlockHandler blockHandler = new BlockHandlerDef() { + @Override + public void blockEnd(@NonNull MarkwonVisitor visitor, @NonNull Node node) { + if (visitor.hasNext(node)) { + visitor.ensureNewLine(); + } + } + }; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.blockHandler(blockHandler); + } + }) + .build(); + + final String md = "" + + "# Hello there!\n\n" + + "* a first\n" + + "* second\n" + + "- third\n" + + "* * nested one\n\n" + + "> block quote\n\n" + + "> > and nested one\n\n" + + "```java\n" + + "final int i = 0;\n" + + "```\n\n"; + + markwon.setMarkdown(textView, md); + } + +// public void step_6() { +// +// final Markwon markwon = Markwon.builder(this) +// .usePlugin(HtmlPlugin.create()) +// .usePlugin(new AbstractMarkwonPlugin() { +// @Override +// public void configure(@NonNull Registry registry) { +// registry.require(HtmlPlugin.class, plugin -> plugin.addHandler(new SimpleTagHandler() { +// @Override +// public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { +// return new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER); +// } +// +// @NonNull +// @Override +// public Collection supportedTags() { +// return Collections.singleton("center"); +// } +// })); +// } +// }) +// .build(); +// } + // text lifecycle (after/before) // rendering lifecycle (before/after) // renderProps // process - // priority + + private static class AnchorSpan { + final String anchor; + + AnchorSpan(@NonNull String anchor) { + this.anchor = anchor; + } + } + + @NonNull + private String createAnchor(@NonNull CharSequence content) { + return String.valueOf(content) + .replaceAll("[^\\w]", "") + .toLowerCase(); + } + + private static class AnchorLinkResolver extends LinkResolverDef { + + interface ScrollTo { + void scrollTo(@NonNull View view, int top); + } + + private final ScrollTo scrollTo; + + AnchorLinkResolver(@NonNull ScrollTo scrollTo) { + this.scrollTo = scrollTo; + } + + @Override + public void resolve(@NonNull View view, @NonNull String link) { + if (link.startsWith("#")) { + final TextView textView = (TextView) view; + final Spanned spanned = (Spannable) textView.getText(); + final AnchorSpan[] spans = spanned.getSpans(0, spanned.length(), AnchorSpan.class); + if (spans != null) { + final String anchor = link.substring(1); + for (AnchorSpan span: spans) { + if (anchor.equals(span.anchor)) { + final int start = spanned.getSpanStart(span); + final int line = textView.getLayout().getLineForOffset(start); + final int top = textView.getLayout().getLineTop(line); + scrollTo.scrollTo(textView, top); + return; + } + } + } + } + super.resolve(view, link); + } + } + + private void anchor() { + final String lorem = getString(R.string.lorem); + final String md = "" + + "Hello [there](#there)!\n\n\n" + + lorem + "\n\n" + + "# There!\n\n" + + lorem; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { + builder.linkResolver(new AnchorLinkResolver((view, top) -> scrollView.smoothScrollTo(0, top))); + } + + @Override + public void afterSetText(@NonNull TextView textView) { + final Spannable spannable = (Spannable) textView.getText(); + // obtain heading spans + final HeadingSpan[] spans = spannable.getSpans(0, spannable.length(), HeadingSpan.class); + if (spans != null) { + for (HeadingSpan span : spans) { + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + final int flags = spannable.getSpanFlags(span); + spannable.setSpan( + new AnchorSpan(createAnchor(spannable.subSequence(start, end))), + start, + end, + flags + ); + } + } + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } } diff --git a/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java b/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java index f1a67f50..19c6d3dd 100644 --- a/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/core/CoreActivity.java @@ -1,36 +1,48 @@ package io.noties.markwon.sample.core; -import android.app.Activity; import android.os.Bundle; import android.text.Spanned; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.commonmark.node.Node; import io.noties.markwon.Markwon; import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; +import io.noties.markwon.sample.R; -public class CoreActivity extends Activity { +public class CoreActivity extends ActivityWithMenuOptions { private TextView textView; + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("simple", this::simple) + .add("toast", this::toast) + .add("alreadyParsed", this::alreadyParsed); + } + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + setContentView(R.layout.activity_text_view); - textView = new TextView(this); - setContentView(textView); + textView = findViewById(R.id.text_view); - step_1(); +// step_1(); - step_2(); + simple(); - step_3(); - - step_4(); +// toast(); +// +// alreadyParsed(); } /** @@ -70,7 +82,7 @@ public class CoreActivity extends Activity { /** * To simply apply raw (non-parsed) markdown call {@link Markwon#setMarkdown(TextView, String)} */ - private void step_2() { + private void simple() { // this is raw markdown final String markdown = "Hello **markdown**!"; @@ -91,7 +103,7 @@ public class CoreActivity extends Activity { * of invalidation. But if a Toast for example is created with a custom view * ({@code new Toast(this).setView(...) }) and has access to a TextView everything should work. */ - private void step_3() { + private void toast() { final String markdown = "*Toast* __here__!\n\n> And a quote!"; @@ -105,7 +117,7 @@ public class CoreActivity extends Activity { /** * To apply already parsed markdown use {@link Markwon#setParsedMarkdown(TextView, Spanned)} */ - private void step_4() { + private void alreadyParsed() { final String markdown = "This **is** pre-parsed [markdown](#)"; diff --git a/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java b/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java index cb4f178f..cd286198 100644 --- a/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java +++ b/sample/src/main/java/io/noties/markwon/sample/customextension2/CustomExtensionActivity2.java @@ -1,6 +1,5 @@ package io.noties.markwon.sample.customextension2; -import android.app.Activity; import android.os.Bundle; import android.widget.TextView; @@ -25,34 +24,45 @@ import io.noties.markwon.core.CorePlugin; import io.noties.markwon.core.CoreProps; import io.noties.markwon.inlineparser.InlineProcessor; import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.R; -public class CustomExtensionActivity2 extends Activity { +public class CustomExtensionActivity2 extends ActivityWithMenuOptions { + + private static final String MD = "" + + "# Custom Extension 2\n" + + "\n" + + "This is an issue #1\n" + + "Done by @noties"; + + private TextView textView; + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("text_added", this::text_added) + .add("inline_parsing", this::inline_parsing); + } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_text_view); - final TextView textView = findViewById(R.id.text_view); + textView = findViewById(R.id.text_view); // let's look for github special links: // * `#1` - an issue or a pull request // * `@user` link to a user - - final String md = "# Custom Extension 2\n" + - "\n" + - "This is an issue #1\n" + - "Done by @noties"; - - // inline_parsing(textView, md); - text_added(textView, md); + text_added(); } - private void text_added(@NonNull TextView textView, @NonNull String md) { + private void text_added() { final Markwon markwon = Markwon.builder(this) .usePlugin(new AbstractMarkwonPlugin() { @@ -64,10 +74,10 @@ public class CustomExtensionActivity2 extends Activity { }) .build(); - markwon.setMarkdown(textView, md); + markwon.setMarkdown(textView, MD); } - private void inline_parsing(@NonNull TextView textView, @NonNull String md) { + private void inline_parsing() { final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() // include all current defaults (otherwise will be empty - contain only our inline-processors) @@ -86,7 +96,7 @@ public class CustomExtensionActivity2 extends Activity { }) .build(); - markwon.setMarkdown(textView, md); + markwon.setMarkdown(textView, MD); } private static class IssueInlineProcessor extends InlineProcessor { diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java index 5553c9f8..e1181a7f 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/EditorActivity.java @@ -1,11 +1,11 @@ package io.noties.markwon.sample.editor; -import android.app.Activity; import android.os.Bundle; import android.text.Editable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextPaint; +import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.style.ForegroundColorSpan; import android.text.style.MetricAffectingSpan; @@ -41,30 +41,61 @@ import io.noties.markwon.inlineparser.BangInlineProcessor; import io.noties.markwon.inlineparser.EntityInlineProcessor; import io.noties.markwon.inlineparser.HtmlInlineProcessor; import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.linkify.LinkifyPlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.R; -public class EditorActivity extends Activity { +public class EditorActivity extends ActivityWithMenuOptions { private EditText editText; + private String pendingInput; + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("simpleProcess", this::simple_process) + .add("simplePreRender", this::simple_pre_render) + .add("customPunctuationSpan", this::custom_punctuation_span) + .add("additionalEditSpan", this::additional_edit_span) + .add("additionalPlugins", this::additional_plugins) + .add("multipleEditSpans", this::multiple_edit_spans) + .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) + .add("pluginRequire", this::plugin_require) + .add("pluginNoDefaults", this::plugin_no_defaults); + } + + @Override + protected void beforeOptionSelected(@NonNull String option) { + // we cannot _clear_ editText of text-watchers without keeping a reference to them... + pendingInput = editText != null + ? editText.getText().toString() + : null; + + createView(); + } + + @Override + protected void afterOptionSelected(@NonNull String option) { + if (!TextUtils.isEmpty(pendingInput)) { + editText.setText(pendingInput); + } + } + + private void createView() { + setContentView(R.layout.activity_editor); + + this.editText = findViewById(R.id.edit_text); + + initBottomBar(); + } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_editor); - - this.editText = findViewById(R.id.edit_text); - initBottomBar(); - -// simple_process(); - -// simple_pre_render(); - -// custom_punctuation_span(); - -// additional_edit_span(); - -// additional_plugins(); + createView(); multiple_edit_spans(); } @@ -216,6 +247,76 @@ public class EditorActivity extends Activity { editor, Executors.newSingleThreadExecutor(), editText)); } + private void multiple_edit_spans_plugin() { + // inline parsing is configured via MarkwonInlineParserPlugin + + // for links to be clickable + editText.setMovementMethod(LinkMovementMethod.getInstance()); + + final Markwon markwon = Markwon.builder(this) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(LinkifyPlugin.create()) + .usePlugin(MarkwonInlineParserPlugin.create(builder -> { + builder + .excludeInlineProcessor(BangInlineProcessor.class) + .excludeInlineProcessor(HtmlInlineProcessor.class) + .excludeInlineProcessor(EntityInlineProcessor.class); + })) + .build(); + + final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); + + final MarkwonEditor editor = MarkwonEditor.builder(markwon) + .useEditHandler(new EmphasisEditHandler()) + .useEditHandler(new StrongEmphasisEditHandler()) + .useEditHandler(new StrikethroughEditHandler()) + .useEditHandler(new CodeEditHandler()) + .useEditHandler(new BlockQuoteEditHandler()) + .useEditHandler(new LinkEditHandler(onClick)) + .build(); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); + } + + private void plugin_require() { + // usage of plugin from other plugins + + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class) + .factoryBuilder() + .excludeInlineProcessor(HtmlInlineProcessor.class); + } + }) + .build(); + + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); + } + + private void plugin_no_defaults() { + // a plugin with no defaults registered + + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults())) +// .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults(), factoryBuilder -> { +// // if anything, they can be included here +//// factoryBuilder.includeDefaults() +// })) + .build(); + + final MarkwonEditor editor = MarkwonEditor.create(markwon); + + editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( + editor, Executors.newSingleThreadExecutor(), editText)); + } + private void initBottomBar() { // all except block-quote wraps if have selection, or inserts at current cursor position diff --git a/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java index 743428d0..3a6d60fd 100644 --- a/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java +++ b/sample/src/main/java/io/noties/markwon/sample/editor/LinkEditHandler.java @@ -40,24 +40,28 @@ class LinkEditHandler extends AbstractEditHandler { final EditLinkSpan editLinkSpan = persistedSpans.get(EditLinkSpan.class); editLinkSpan.link = span.getLink(); - final int s; - final int e; + // First first __letter__ to find link content (scheme start in URL, receiver in email address) + // NB! do not use phone number auto-link (via LinkifyPlugin) as we cannot guarantee proper link + // display. For example, we _could_ also look for a digit, but: + // * if phone number start with special symbol, we won't have it (`+`, `(`) + // * it might interfere with an ordered-list + int start = -1; - // markdown link vs. autolink - if ('[' == input.charAt(spanStart)) { - s = spanStart + 1; - e = spanStart + 1 + spanTextLength; - } else { - s = spanStart; - e = spanStart + spanTextLength; + for (int i = spanStart, length = input.length(); i < length; i++) { + if (Character.isLetter(input.charAt(i))) { + start = i; + break; + } } - editable.setSpan( - editLinkSpan, - s, - e, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ); + if (start > -1) { + editable.setSpan( + editLinkSpan, + start, + start + spanTextLength, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } } @NonNull diff --git a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java index 21613985..db5ca541 100644 --- a/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/html/HtmlActivity.java @@ -1,6 +1,5 @@ package io.noties.markwon.sample.html; -import android.app.Activity; import android.os.Bundle; import android.text.Layout; import android.text.TextUtils; @@ -12,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; +import org.commonmark.node.Paragraph; + import java.util.Collection; import java.util.Collections; import java.util.Random; @@ -27,9 +28,24 @@ import io.noties.markwon.html.HtmlTag; import io.noties.markwon.html.MarkwonHtmlRenderer; import io.noties.markwon.html.TagHandler; import io.noties.markwon.html.tag.SimpleTagHandler; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.R; -public class HtmlActivity extends Activity { +public class HtmlActivity extends ActivityWithMenuOptions { + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("align", this::align) + .add("randomCharSize", this::randomCharSize) + .add("enhance", this::enhance) + .add("image", this::image); + } + + private TextView textView; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -39,35 +55,9 @@ public class HtmlActivity extends Activity { // let's define some custom tag-handlers - final TextView textView = findViewById(R.id.text_view); + textView = findViewById(R.id.text_view); - final Markwon markwon = Markwon.builder(this) - .usePlugin(HtmlPlugin.create()) - .usePlugin(new AbstractMarkwonPlugin() { - @Override - public void configure(@NonNull Registry registry) { - registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin - .addHandler(new AlignTagHandler()) - .addHandler(new RandomCharSize(new Random(42L), textView.getTextSize())) - .addHandler(new EnhanceTagHandler((int) (textView.getTextSize() * 2 + .05F)))); - } - }) - .build(); - - final String markdown = "# Hello, HTML\n" + - "\n" + - "We are centered\n" + - "\n" + - "We are at the end\n" + - "\n" + - "We should be at the start\n" + - "\n" + - "\n" + - "This message should have a jumpy feeling because of different sizes of characters\n" + - "\n\n" + - "This is text that must be enhanced, at least a part of it"; - - markwon.setMarkdown(textView, markdown); + align(); } // we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content @@ -105,6 +95,31 @@ public class HtmlActivity extends Activity { } } + private void align() { + + final String md = "" + + "We are centered\n" + + "\n" + + "We are at the end\n" + + "\n" + + "We should be at the start\n" + + "\n"; + + + final Markwon markwon = Markwon.builder(this) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new AlignTagHandler())); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + // each character will have random size private static class RandomCharSize extends TagHandler { @@ -139,6 +154,27 @@ public class HtmlActivity extends Activity { } } + private void randomCharSize() { + + final String md = "" + + "\n" + + "This message should have a jumpy feeling because of different sizes of characters\n" + + "\n\n"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new RandomCharSize(new Random(42L), textView.getTextSize()))); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + private static class EnhanceTagHandler extends TagHandler { private final int enhanceTextSize; @@ -187,4 +223,49 @@ public class HtmlActivity extends Activity { return position; } } + + private void enhance() { + + final String md = "" + + "This is text that must be enhanced, at least a part of it"; + + + final Markwon markwon = Markwon.builder(this) + .usePlugin(HtmlPlugin.create()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(HtmlPlugin.class, htmlPlugin -> htmlPlugin + .addHandler(new EnhanceTagHandler((int) (textView.getTextSize() * 2 + .05F)))); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void image() { + // treat unclosed/void `img` tag as HTML inline + final String md = "" + + "## Try CommonMark\n" + + "\n" + + "Markwon IMG:\n" + + "\n" + + "![](https://upload.wikimedia.org/wikipedia/it/thumb/c/c5/GTA_2.JPG/220px-GTA_2.JPG)\n" + + "\n" + + "New lines...\n" + + "\n" + + "HTML IMG:\n" + + "\n" + + "\n" + + "\n" + + "New lines\n\n"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(ImagesPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .build(); + + markwon.setMarkdown(textView, md); + } } diff --git a/sample/src/main/java/io/noties/markwon/sample/images/ImagesActivity.java b/sample/src/main/java/io/noties/markwon/sample/images/ImagesActivity.java new file mode 100644 index 00000000..6206a2c4 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/images/ImagesActivity.java @@ -0,0 +1,99 @@ +package io.noties.markwon.sample.images; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.method.LinkMovementMethod; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.request.target.Target; + +import io.noties.markwon.Markwon; +import io.noties.markwon.image.AsyncDrawable; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.image.glide.GlideImagesPlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; +import io.noties.markwon.sample.R; + +public class ImagesActivity extends ActivityWithMenuOptions { + + private TextView textView; + + @NonNull + @Override + public MenuOptions menuOptions() { + // todo: same for other plugins + return MenuOptions.create() + .add("glide-singleImage", this::glideSingleImage) + .add("glide-singleImageWithPlaceholder", this::glideSingleImageWithPlaceholder) + .add("click", this::click); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_text_view); + textView = findViewById(R.id.text_view); + + glideSingleImageWithPlaceholder(); + } + + private void glideSingleImage() { + final String md = "[![undefined](https://img.youtube.com/vi/gs1I8_m4AOM/0.jpg)](https://www.youtube.com/watch?v=gs1I8_m4AOM)"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(GlideImagesPlugin.create(this)) + .build(); + + markwon.setMarkdown(textView, md); + } + + // can be checked when used first, otherwise works as expected... + private void glideSingleImageWithPlaceholder() { + final String md = "[![undefined](https://img.youtube.com/vi/gs1I8_m4AOM/0.jpg)](https://www.youtube.com/watch?v=gs1I8_m4AOM)"; + + final Context context = this; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(GlideImagesPlugin.create(new GlideImagesPlugin.GlideStore() { + @NonNull + @Override + public RequestBuilder load(@NonNull AsyncDrawable drawable) { +// final Drawable placeholder = ContextCompat.getDrawable(context, R.drawable.ic_home_black_36dp); +// placeholder.setBounds(0, 0, 100, 100); + return Glide.with(context) + .load(drawable.getDestination()) +// .placeholder(ContextCompat.getDrawable(context, R.drawable.ic_home_black_36dp)); +// .placeholder(placeholder); + .placeholder(R.drawable.ic_home_black_36dp); + } + + @Override + public void cancel(@NonNull Target target) { + Glide.with(context) + .clear(target); + } + })) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void click() { + + textView.setMovementMethod(LinkMovementMethod.getInstance()); + + final String md = "[![markdown](https://www.mdeditor.com/images/logos/markdown.png \"markdown\")](https://www.mdeditor.com/images/logos/markdown.png)"; + final Markwon markwon = Markwon.builder(this) + .usePlugin(ImagesPlugin.create()) + .build(); + markwon.setMarkdown(textView, md); + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java index 27d069eb..4e7c87da 100644 --- a/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/inlineparser/InlineParserActivity.java @@ -1,6 +1,5 @@ package io.noties.markwon.sample.inlineparser; -import android.app.Activity; import android.os.Bundle; import android.widget.TextView; @@ -25,19 +24,32 @@ import io.noties.markwon.Markwon; import io.noties.markwon.inlineparser.BackticksInlineProcessor; import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; import io.noties.markwon.inlineparser.MarkwonInlineParser; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.R; -public class InlineParserActivity extends Activity { +public class InlineParserActivity extends ActivityWithMenuOptions { private TextView textView; + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("links_only", this::links_only) + .add("disable_code", this::disable_code) + .add("pluginWithDefaults", this::pluginWithDefaults) + .add("pluginNoDefaults", this::pluginNoDefaults); + } + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_text_view); - this.textView = findViewById(R.id.text_view); + textView = findViewById(R.id.text_view); // links_only(); @@ -115,4 +127,50 @@ public class InlineParserActivity extends Activity { "**Good day!**"; markwon.setMarkdown(textView, md); } + + private void pluginWithDefaults() { + // a plugin with defaults registered + + final String md = "no [links](#) for **you** `code`!"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + // the same as: +// .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilder())) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .excludeInlineProcessor(OpenBracketInlineProcessor.class); + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void pluginNoDefaults() { + // a plugin with NO defaults registered + + final String md = "no [links](#) for **you** `code`!"; + + final Markwon markwon = Markwon.builder(this) + // pass `MarkwonInlineParser.factoryBuilderNoDefaults()` no disable all + .usePlugin(MarkwonInlineParserPlugin.create(MarkwonInlineParser.factoryBuilderNoDefaults())) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder() + .addInlineProcessor(new BackticksInlineProcessor()); + }); + } + }) + .build(); + + markwon.setMarkdown(textView, md); + } + } diff --git a/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java b/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java index 44d9aac3..b8a7d9e3 100644 --- a/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java +++ b/sample/src/main/java/io/noties/markwon/sample/latex/LatexActivity.java @@ -1,29 +1,31 @@ package io.noties.markwon.sample.latex; -import android.app.Activity; +import android.content.res.Resources; +import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import io.noties.debug.Debug; import io.noties.markwon.Markwon; import io.noties.markwon.ext.latex.JLatexMathPlugin; +import io.noties.markwon.ext.latex.JLatexMathTheme; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; import io.noties.markwon.sample.R; -import ru.noties.jlatexmath.JLatexMathDrawable; -public class LatexActivity extends Activity { +public class LatexActivity extends ActivityWithMenuOptions { - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_text_view); - - final TextView textView = findViewById(R.id.text_view); + private static final String LATEX_ARRAY; + static { String latex = "\\begin{array}{l}"; latex += "\\forall\\varepsilon\\in\\mathbb{R}_+^*\\ \\exists\\eta>0\\ |x-x_0|\\leq\\eta\\Longrightarrow|f(x)-f(x_0)|\\leq\\varepsilon\\\\"; latex += "\\det\\begin{bmatrix}a_{11}&a_{12}&\\cdots&a_{1n}\\\\a_{21}&\\ddots&&\\vdots\\\\\\vdots&&\\ddots&\\vdots\\\\a_{n1}&\\cdots&\\cdots&a_{nn}\\end{bmatrix}\\overset{\\mathrm{def}}{=}\\sum_{\\sigma\\in\\mathfrak{S}_n}\\varepsilon(\\sigma)\\prod_{k=1}^n a_{k\\sigma(k)}\\\\"; @@ -34,61 +36,222 @@ public class LatexActivity extends Activity { latex += "L = \\int_a^b \\sqrt{ \\left|\\sum_{i,j=1}^ng_{ij}(\\gamma(t))\\left(\\frac{d}{dt}x^i\\circ\\gamma(t)\\right)\\left(\\frac{d}{dt}x^j\\circ\\gamma(t)\\right)\\right|}\\,dt\\\\"; latex += "\\begin{array}{rl} s &= \\int_a^b\\left\\|\\frac{d}{dt}\\vec{r}\\,(u(t),v(t))\\right\\|\\,dt \\\\ &= \\int_a^b \\sqrt{u'(t)^2\\,\\vec{r}_u\\cdot\\vec{r}_u + 2u'(t)v'(t)\\, \\vec{r}_u\\cdot\\vec{r}_v+ v'(t)^2\\,\\vec{r}_v\\cdot\\vec{r}_v}\\,\\,\\, dt. \\end{array}\\\\"; latex += "\\end{array}"; + LATEX_ARRAY = latex; + } -// String latex = "\\text{A long division \\longdiv{12345}{13}"; -// String latex = "{a \\bangle b} {c \\brace d} {e \\brack f} {g \\choose h}"; + private static final String LATEX_LONG_DIVISION = "\\text{A long division \\longdiv{12345}{13}"; + private static final String LATEX_BANGLE = "{a \\bangle b} {c \\brace d} {e \\brack f} {g \\choose h}"; + private static final String LATEX_BOXES; -// 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}"; + static { + 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}"; + LATEX_BOXES = latex; + } - final String markdown = "# Example of LaTeX\n\n$$" - + latex + "$$\n\n something like **this**"; + private TextView textView; + private View parent; + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("array", this::array) + .add("longDivision", this::longDivision) + .add("bangle", this::bangle) + .add("boxes", this::boxes) + .add("insideBlockQuote", this::insideBlockQuote) + .add("error", this::error) + .add("legacy", this::legacy) + .add("textColor", this::textColor) + .add("defaultTextColor", this::defaultTextColor) + .add("inlineAndBlock", this::inlineAndBlock) + .add("dark", this::dark); + } + + @Override + protected void beforeOptionSelected(@NonNull String option) { + super.beforeOptionSelected(option); + + // reset text color + textView.setTextColor(0xFF000000); + + // reset background + parent.setBackgroundColor(0xFFffffff); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_text_view); + + textView = findViewById(R.id.text_view); + parent = findViewById(R.id.scroll_view); + +// array(); + longDivision(); + } + + private void array() { + renderWithBlocksAndInlines(wrapLatexInSampleMarkdown(LATEX_ARRAY)); + } + + private void longDivision() { + renderWithBlocksAndInlines(wrapLatexInSampleMarkdown(LATEX_LONG_DIVISION)); + } + + private void bangle() { + renderWithBlocksAndInlines(wrapLatexInSampleMarkdown(LATEX_BANGLE)); + } + + private void boxes() { + renderWithBlocksAndInlines(wrapLatexInSampleMarkdown(LATEX_BOXES)); + } + + private void insideBlockQuote() { + String latex = "W=W_1+W_2=F_1X_1-F_2X_2"; + final String md = "" + + "# LaTeX inside a blockquote\n" + + "> $$" + latex + "$$\n"; + renderWithBlocksAndInlines(md); + } + + private void error() { + final String md = wrapLatexInSampleMarkdown("\\sum_{i=0}^\\infty x \\cdot 0 \\rightarrow \\iMightNotExist{0}"); final Markwon markwon = Markwon.builder(this) -// .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), new JLatexMathPlugin.BuilderConfigure() { -// @Override -// public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { -// builder -// .backgroundProvider(new JLatexMathPlugin.BackgroundProvider() { -// @NonNull -// @Override -// public Drawable provide() { -// return new ColorDrawable(0x40ff0000); -// } -// }) -// .fitCanvas(true) -// .align(JLatexMathDrawable.ALIGN_LEFT) -// .padding(48) -// ; -// } -// })) - .usePlugin(JLatexMathPlugin.create(textView.getTextSize())) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + //noinspection Convert2Lambda + builder.errorHandler(new JLatexMathPlugin.ErrorHandler() { + @Nullable + @Override + public Drawable handleError(@Nullable String latex, @NonNull Throwable error) { + Debug.e(error, latex); + return ContextCompat.getDrawable(LatexActivity.this, R.drawable.ic_android_black_24dp); + } + }); + })) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void legacy() { + final String md = wrapLatexInSampleMarkdown(LATEX_BANGLE); + + final Markwon markwon = Markwon.builder(this) + // LEGACY does not require inline parser + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.blocksLegacy(true); + builder.theme() + .backgroundProvider(() -> new ColorDrawable(0x100000ff)) + .padding(JLatexMathTheme.Padding.all(48)); + })) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void textColor() { + final String md = wrapLatexInSampleMarkdown(LATEX_LONG_DIVISION); + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> { + builder.inlinesEnabled(true); + builder.theme() + .inlineTextColor(Color.RED) + .blockTextColor(Color.GREEN) + .inlineBackgroundProvider(() -> new ColorDrawable(Color.YELLOW)) + .blockBackgroundProvider(() -> new ColorDrawable(Color.GRAY)); + })) + .build(); + markwon.setMarkdown(textView, md); + } + + private void defaultTextColor() { + // @since 4.3.0 text color is automatically taken from textView + // (if it's not specified explicitly via configuration) + textView.setTextColor(0xFFff0000); + + final String md = wrapLatexInSampleMarkdown(LATEX_LONG_DIVISION); + final Markwon markwon = Markwon.builder(this) + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), new JLatexMathPlugin.BuilderConfigure() { + @Override + public void configureBuilder(@NonNull JLatexMathPlugin.Builder builder) { + builder.inlinesEnabled(true); + // override default text color + builder.theme() + .inlineTextColor(0xFF00ffff); + } + })) + .build(); + + markwon.setMarkdown(textView, md); + } + + private void inlineAndBlock() { + final String md = "" + + "# Inline and block\n\n" + + "$$\\int_{a}^{b} f(x)dx = F(b) - F(a)$$\n\n" + + "this was **inline** _LaTeX_ $$\\int_{a}^{b} f(x)dx = F(b) - F(a)$$ and once again it was\n\n" + + "Now a block:\n\n" + + "$$\n" + + "\\int_{a}^{b} f(x)dx = F(b) - F(a)\n" + + "$$\n\n" + + "Not a block (content on delimited line), but inline instead:\n\n" + + "$$\\int_{a}^{b} f(x)dx = F(b) - F(a)$$" + + "\n\n" + + "that's it"; + renderWithBlocksAndInlines(md); + } + + private void dark() { + parent.setBackgroundColor(0xFF000000); + textView.setTextColor(0xFFffffff); + + String latex = "W=W_1+W_2=F_1X_1-F_2X_2"; + final String md = "" + + "# LaTeX inside a blockquote\n" + + "> $$" + latex + "$$\n"; + renderWithBlocksAndInlines(md); + } + + @NonNull + private static String wrapLatexInSampleMarkdown(@NonNull String latex) { + return "" + + "# Example of LaTeX\n\n" + + "(inline): $$" + latex + "$$ so nice, really-really really-really really-really? Now, (block):\n\n" + + "$$\n" + + "" + latex + "\n" + + "$$\n\n" + + "the end"; + } + + private void renderWithBlocksAndInlines(@NonNull String markdown) { + + final float textSize = textView.getTextSize(); + final Resources r = getResources(); + + final Markwon markwon = Markwon.builder(this) + // NB! `MarkwonInlineParserPlugin` is required in order to parse inlines + .usePlugin(MarkwonInlineParserPlugin.create()) + .usePlugin(JLatexMathPlugin.create(textSize, textSize * 1.25F, builder -> { + // Important thing to do is to enable inlines (by default disabled) + builder.inlinesEnabled(true); + builder.theme() + .inlineBackgroundProvider(() -> new ColorDrawable(0x1000ff00)) + .blockBackgroundProvider(() -> new ColorDrawable(0x10ff0000)) + .blockPadding(JLatexMathTheme.Padding.symmetric( + r.getDimensionPixelSize(R.dimen.latex_block_padding_vertical), + r.getDimensionPixelSize(R.dimen.latex_block_padding_horizontal) + )); + })) .build(); -// -// if (true) { -//// final String l = "$$\n" + -//// " P(X=r)=\\frac{\\lambda^r e^{-\\lambda}}{r!}\n" + -//// "$$\n" + -//// "\n" + -//// "$$\n" + -//// " P(Xr)=1-P(X new StyleSpan(Typeface.BOLD)) + .setFactory(Emphasis.class, (configuration, props) -> new StyleSpan(Typeface.ITALIC)); + } + }) + .build(); + display(markwon.toMarkdown(md)); + } + + private void heading() { + + // please note that heading doesn't seem to be working in remote views, + // tried both `RelativeSizeSpan` and `AbsoluteSizeSpan` with no effect + + final float base = 12; + + final float[] sizes = { + 2.F, 1.5F, 1.17F, 1.F, .83F, .67F, + }; + + final String md = "" + + "# H1\n" + + "## H2\n" + + "### H3\n" + + "#### H4\n" + + "##### H5\n" + + "###### H6\n\n"; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(Heading.class, (configuration, props) -> { + final Integer level = CoreProps.HEADING_LEVEL.get(props); + Debug.i(level); + if (level != null && level > 0 && level <= sizes.length) { +// return new RelativeSizeSpan(sizes[level - 1]); + final Object span = new AbsoluteSizeSpan((int) (base * sizes[level - 1] + .5F), true); + return new Object[]{ + span, + new StyleSpan(Typeface.BOLD) + }; + } + return null; + }); + } + }) + .build(); + display(markwon.toMarkdown(md)); + } + + private void lists() { + final String md = "" + + "* bullet 1\n" + + "* bullet 2\n" + + "* * bullet 2 1\n" + + " * bullet 2 0 1\n" + + "1) order 1\n" + + "1) order 2\n" + + "1) order 3\n"; + + // ordered lists _could_ be translated to raw text representation (`1.`, `1)` etc) in resulting markdown + // or they could be _disabled_ all together... (can ordered lists be disabled in parser?) + + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(ListItem.class, (configuration, props) -> { + final CoreProps.ListItemType type = CoreProps.LIST_ITEM_TYPE.get(props); + if (type != null) { + // bullet and ordered list share the same markdown node + return new BulletSpan(); + } + return null; + }); + } + }) + .build(); + + display(markwon.toMarkdown(md)); + } + + private void image() { + // please note that image _could_ be supported only if it would be available immediately + // debugging possibility + // + // doesn't seem to be working + + final Bitmap bitmap = Bitmap.createBitmap(128, 256, Bitmap.Config.ARGB_4444); + final Canvas canvas = new Canvas(bitmap); + canvas.drawColor(0xFFAD1457); + + final SpannableStringBuilder builder = new SpannableStringBuilder(); + builder.append("An image: "); + + final int length = builder.length(); + builder.append("[bitmap]"); + builder.setSpan( + new ImageSpan(this, bitmap, DynamicDrawableSpan.ALIGN_BOTTOM), + length, + builder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + + builder.append(" okay, and "); + + final int start = builder.length(); + builder.append("[resource]"); + builder.setSpan( + new ImageSpan(this, R.drawable.ic_memory_black_48dp), + start, + builder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + + display(builder); + } + + private void link() { + final String md = "" + + "[a link](https://isa.link/) is here, styling yes, clicking - no"; + display(Markwon.create(this).toMarkdown(md)); + } + + private void blockquote() { + final String md = "" + + "> This was once said by me\n" + + "> > And this one also\n\n" + + "Me"; + final Markwon markwon = Markwon.builder(this) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(BlockQuote.class, (configuration, props) -> new QuoteSpan()); + } + }) + .build(); + display(markwon.toMarkdown(md)); + } + + private void strikethrough() { + final String md = "~~strike that!~~"; + final Markwon markwon = Markwon.builder(this) + .usePlugin(new StrikethroughPlugin()) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(Strikethrough.class, (configuration, props) -> new StrikethroughSpan()); + } + }) + .build(); + display(markwon.toMarkdown(md)); + } + + private void display(@NonNull CharSequence cs) { + final NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null) { + throw new IllegalStateException("No NotificationManager is available"); + } + + ensureChannel(manager); + + final Notification.Builder builder = new Notification.Builder(this) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle("Markwon") + .setContentText(cs) + .setStyle(new Notification.BigTextStyle().bigText(cs)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setChannelId(CHANNEL_ID); + } + + manager.notify(1, builder.build()); + } + + private void ensureChannel(@NonNull NotificationManager manager) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + + final NotificationChannel channel = manager.getNotificationChannel(CHANNEL_ID); + if (channel == null) { + manager.createNotificationChannel(new NotificationChannel( + CHANNEL_ID, + CHANNEL_ID, + NotificationManager.IMPORTANCE_DEFAULT)); + } + } +} diff --git a/sample/src/main/java/io/noties/markwon/sample/tasklist/TaskListActivity.java b/sample/src/main/java/io/noties/markwon/sample/tasklist/TaskListActivity.java new file mode 100644 index 00000000..b5c421d6 --- /dev/null +++ b/sample/src/main/java/io/noties/markwon/sample/tasklist/TaskListActivity.java @@ -0,0 +1,169 @@ +package io.noties.markwon.sample.tasklist; + +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import java.util.Objects; + +import io.noties.debug.Debug; +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.SpanFactory; +import io.noties.markwon.ext.tasklist.TaskListItem; +import io.noties.markwon.ext.tasklist.TaskListPlugin; +import io.noties.markwon.ext.tasklist.TaskListSpan; +import io.noties.markwon.sample.ActivityWithMenuOptions; +import io.noties.markwon.sample.MenuOptions; +import io.noties.markwon.sample.R; + +public class TaskListActivity extends ActivityWithMenuOptions { + + private static final String MD = "" + + "- [ ] Not done here!\n" + + "- [x] and done\n" + + "- [X] and again!\n" + + "* [ ] **and** syntax _included_ `code`\n" + + "- [ ] [link](#)\n" + + "- [ ] [a check box](https://goog.le)\n" + + "- [x] [test]()\n" + + "- [List](https://goog.le) 3"; + + private TextView textView; + + @NonNull + @Override + public MenuOptions menuOptions() { + return MenuOptions.create() + .add("regular", this::regular) + .add("customColors", this::customColors) + .add("customDrawableResources", this::customDrawableResources) + .add("mutate", this::mutate); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_text_view); + + textView = findViewById(R.id.text_view); + +// mutate(); + regular(); + } + + private void regular() { + // default theme + + final Markwon markwon = Markwon.builder(this) + .usePlugin(TaskListPlugin.create(this)) + .build(); + + markwon.setMarkdown(textView, MD); + } + + private void customColors() { + + final int checkedFillColor = Color.RED; + final int normalOutlineColor = Color.GREEN; + final int checkMarkColor = Color.BLUE; + + final Markwon markwon = Markwon.builder(this) + .usePlugin(TaskListPlugin.create(checkedFillColor, normalOutlineColor, checkMarkColor)) + .build(); + + markwon.setMarkdown(textView, MD); + } + + private void customDrawableResources() { + // drawable **must** be stateful + + final Drawable drawable = Objects.requireNonNull( + ContextCompat.getDrawable(this, R.drawable.custom_task_list)); + + final Markwon markwon = Markwon.builder(this) + .usePlugin(TaskListPlugin.create(drawable)) + .build(); + + markwon.setMarkdown(textView, MD); + } + + private void mutate() { + + final Markwon markwon = Markwon.builder(this) + .usePlugin(TaskListPlugin.create(this)) + .usePlugin(new AbstractMarkwonPlugin() { + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + // obtain origin task-list-factory + final SpanFactory origin = builder.getFactory(TaskListItem.class); + if (origin == null) { + return; + } + + builder.setFactory(TaskListItem.class, (configuration, props) -> { + // maybe it's better to validate the actual type here also + // and not force cast to task-list-span + final TaskListSpan span = (TaskListSpan) origin.getSpans(configuration, props); + if (span == null) { + return null; + } + + // NB, toggle click will intercept possible links inside task-list-item + return new Object[]{ + span, + new TaskListToggleSpan(span) + }; + }); + } + }) + .build(); + + markwon.setMarkdown(textView, MD); + } + + private static class TaskListToggleSpan extends ClickableSpan { + + private final TaskListSpan span; + + TaskListToggleSpan(@NonNull TaskListSpan span) { + this.span = span; + } + + @Override + public void onClick(@NonNull View widget) { + // toggle span (this is a mere visual change) + span.setDone(!span.isDone()); + // request visual update + widget.invalidate(); + + // it must be a TextView + final TextView textView = (TextView) widget; + // it must be spanned + final Spanned spanned = (Spanned) textView.getText(); + + // actual text of the span (this can be used along with the `span`) + final CharSequence task = spanned.subSequence( + spanned.getSpanStart(this), + spanned.getSpanEnd(this) + ); + + Debug.i("task done: %s, '%s'", span.isDone(), task); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + // no op, so text is not rendered as a link + } + } +} diff --git a/sample/src/main/res/drawable-anydpi-v24/ic_stat_name.xml b/sample/src/main/res/drawable-anydpi-v24/ic_stat_name.xml new file mode 100644 index 00000000..fd7cefc2 --- /dev/null +++ b/sample/src/main/res/drawable-anydpi-v24/ic_stat_name.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/sample/src/main/res/drawable-hdpi/ic_stat_name.png b/sample/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 00000000..19e7a26b Binary files /dev/null and b/sample/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/sample/src/main/res/drawable-mdpi/ic_stat_name.png b/sample/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 00000000..0525d874 Binary files /dev/null and b/sample/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/sample/src/main/res/drawable-xhdpi/ic_stat_name.png b/sample/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 00000000..c5f2f076 Binary files /dev/null and b/sample/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/sample/src/main/res/drawable-xxhdpi/ic_stat_name.png b/sample/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 00000000..993df1f0 Binary files /dev/null and b/sample/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/sample/src/main/res/drawable/custom_task_list.xml b/sample/src/main/res/drawable/custom_task_list.xml new file mode 100644 index 00000000..43c2e2a8 --- /dev/null +++ b/sample/src/main/res/drawable/custom_task_list.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_text_view.xml b/sample/src/main/res/layout/activity_text_view.xml index 9828f257..e557a4bc 100644 --- a/sample/src/main/res/layout/activity_text_view.xml +++ b/sample/src/main/res/layout/activity_text_view.xml @@ -1,13 +1,16 @@ + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false" + android:padding="8dip"> + + 8dip + 16dip + \ No newline at end of file diff --git a/sample/src/main/res/values/strings-samples.xml b/sample/src/main/res/values/strings-samples.xml index d87585fd..0305b471 100644 --- a/sample/src/main/res/values/strings-samples.xml +++ b/sample/src/main/res/values/strings-samples.xml @@ -29,6 +29,12 @@ # \# Inline Parser\n\nUsage of custom inline parser - # \# HTML <details> tag\n\n<details> tag parsed and rendered + # \# HTML\n\n`details` tag parsed and rendered + + # \# TaskList\n\nUsage of TaskListPlugin + + # \# Images\n\nUsage of different images plugins + + # \# Notification\n\nExample usage in notifications and other remote views \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index 5505eb0c..36d27f2a 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -12,4 +12,16 @@ Sentiment Satisfied 64 red: @ic-sentiment_satisfied-red-64 ]]> + +