commit
						3ab015175b
					
				
							
								
								
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -1,5 +1,24 @@ | ||||
| # Changelog | ||||
| 
 | ||||
| # 4.4.0 | ||||
| * `TextViewSpan` to obtain `TextView` in which markdown is displayed (applied by `CorePlugin`) | ||||
| * `TextLayoutSpan` to obtain `Layout` in which markdown is displayed (applied by `TablePlugin`, more specifically `TableRowSpan` to propagate layout in which cell content is displayed) | ||||
| * `HtmlEmptyTagReplacement` now is configurable by `HtmlPlugin`, `iframe` handling ([#235]) | ||||
| * `AsyncDrawable` now uses `TextView` width without padding instead of width of canvas | ||||
| * Support for images inside table cells (`ext-tables` module) | ||||
| * Expose `enabledBlockTypes` in `CorePlugin` | ||||
| * Update `jlatexmath-android` dependency ([#225]) | ||||
| * Update `image-coil` module (Coil version `0.10.1`) ([#244])<br>Thanks to [@tylerbwong] | ||||
| * Rename `UrlProcessor` to `ImageDestinationProcessor` (`io.noties.markwon.urlprocessor` -> `io.noties.markwon.image.destination`) and limit its usage to process **only** destination URL of images (was used to also process links before) | ||||
| * `fallbackToRawInputWhenEmpty` `Markwon.Builder` configuration to fallback to raw input if rendered markdown is empty ([#242])  | ||||
| 
 | ||||
| [#235]: https://github.com/noties/Markwon/issues/235 | ||||
| [#225]: https://github.com/noties/Markwon/issues/225 | ||||
| [#244]: https://github.com/noties/Markwon/pull/244 | ||||
| [#242]: https://github.com/noties/Markwon/issues/242 | ||||
| [@tylerbwong]: https://github.com/tylerbwong | ||||
| 
 | ||||
| 
 | ||||
| # 4.3.1 | ||||
| * Fix DexGuard optimization issue ([#216])<br>Thanks [@francescocervone] | ||||
| * module `images`: `GifSupport` and `SvgSupport` use `Class.forName` instead access to full qualified class name | ||||
|  | ||||
| @ -5,15 +5,15 @@ import android.text.TextUtils; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.urlprocessor.UrlProcessor; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessor; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; | ||||
| 
 | ||||
| class UrlProcessorInitialReadme implements UrlProcessor { | ||||
| class ImageDestinationProcessorInitialReadme extends ImageDestinationProcessor { | ||||
| 
 | ||||
|     private static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/"; | ||||
| 
 | ||||
|     private final UrlProcessorRelativeToAbsolute processor | ||||
|             = new UrlProcessorRelativeToAbsolute(GITHUB_BASE); | ||||
|     private final ImageDestinationProcessorRelativeToAbsolute processor | ||||
|             = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE); | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
| @ -24,6 +24,8 @@ import io.noties.markwon.ext.tables.TablePlugin; | ||||
| import io.noties.markwon.ext.tasklist.TaskListPlugin; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.image.ImagesPlugin; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessor; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; | ||||
| import io.noties.markwon.image.file.FileSchemeHandler; | ||||
| import io.noties.markwon.image.gif.GifMediaDecoder; | ||||
| import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; | ||||
| @ -31,8 +33,6 @@ import io.noties.markwon.syntax.Prism4jTheme; | ||||
| import io.noties.markwon.syntax.Prism4jThemeDarkula; | ||||
| import io.noties.markwon.syntax.Prism4jThemeDefault; | ||||
| import io.noties.markwon.syntax.SyntaxHighlightPlugin; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessor; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; | ||||
| import io.noties.prism4j.Prism4j; | ||||
| 
 | ||||
| @ActivityScope | ||||
| @ -86,11 +86,11 @@ public class MarkdownRenderer { | ||||
|             } | ||||
| 
 | ||||
|             private void execute() { | ||||
|                 final UrlProcessor urlProcessor; | ||||
|                 final ImageDestinationProcessor imageDestinationProcessor; | ||||
|                 if (uri == null) { | ||||
|                     urlProcessor = new UrlProcessorInitialReadme(); | ||||
|                     imageDestinationProcessor = new ImageDestinationProcessorInitialReadme(); | ||||
|                 } else { | ||||
|                     urlProcessor = new UrlProcessorRelativeToAbsolute(uri.toString()); | ||||
|                     imageDestinationProcessor = new ImageDestinationProcessorRelativeToAbsolute(uri.toString()); | ||||
|                 } | ||||
| 
 | ||||
|                 final Prism4jTheme prism4jTheme = isLightTheme | ||||
| @ -119,7 +119,7 @@ public class MarkdownRenderer { | ||||
|                         .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                             @Override | ||||
|                             public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { | ||||
|                                 builder.urlProcessor(urlProcessor); | ||||
|                                 builder.imageDestinationProcessor(imageDestinationProcessor); | ||||
|                             } | ||||
|                         }) | ||||
|                         .build(); | ||||
|  | ||||
| @ -72,7 +72,7 @@ ext { | ||||
|             'commonmark-table'        : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion", | ||||
|             'android-svg'             : 'com.caverock:androidsvg:1.4', | ||||
|             'android-gif'             : 'pl.droidsonroids.gif:android-gif-drawable:1.2.15', | ||||
|             'jlatexmath-android'      : 'ru.noties:jlatexmath-android:0.1.1', | ||||
|             'jlatexmath-android'      : 'ru.noties:jlatexmath-android:0.2.0', | ||||
|             'okhttp'                  : 'com.squareup.okhttp3:okhttp:3.9.0', | ||||
|             'prism4j'                 : 'io.noties:prism4j:2.0.0', | ||||
|             'debug'                   : 'io.noties:debug:5.0.0@jar', | ||||
| @ -80,7 +80,7 @@ ext { | ||||
|             'dagger'                  : "com.google.dagger:dagger:$daggerVersion", | ||||
|             'picasso'                 : 'com.squareup.picasso:picasso:2.71828', | ||||
|             'glide'                   : 'com.github.bumptech.glide:glide:4.9.0', | ||||
|             'coil'                    : 'io.coil-kt:coil:0.8.0' | ||||
|             'coil'                    : 'io.coil-kt:coil:0.10.1' | ||||
|     ] | ||||
| 
 | ||||
|     deps['annotationProcessor'] = [ | ||||
|  | ||||
| @ -99,12 +99,7 @@ and 2 themes included: Light & Dark. It can be downloaded from [releases](ht | ||||
| ## # Awesome Markwon | ||||
| 
 | ||||
| </div> | ||||
| 
 | ||||
| <u>Applications using Markwon</u>: | ||||
| 
 | ||||
| * [Partico](https://partiko.app/) - Partiko is a censorship free social network. | ||||
| * [FairNote](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) - simple and intuitive notepad app. It gives you speed and efficiency when you write notes, to-do lists, e-mails, or jot down quick ideas. | ||||
| * [Boxcryptor](https://www.boxcryptor.com) - A software that adds AES-256 and RSA encryption to Dropbox, Google Drive, OneDrive and many other clouds. | ||||
| <br> | ||||
| 
 | ||||
| <AwesomeGroup :apps="[ | ||||
|     {name: 'Nextcloud', image: $withBase(`/assets/apps/nextcloud.png`), link: 'https://github.com/nextcloud/android', description: 'A safe home for all your data. Access & share your files, calendars, contacts, mail & more from any device, on your terms.'}, | ||||
| @ -113,6 +108,11 @@ and 2 themes included: Light & Dark. It can be downloaded from [releases](ht | ||||
|     {name: 'Pure Writer', image: $withBase(`/assets/apps/purewriter.png`), link: 'https://play.google.com/store/apps/details?id=com.drakeet.purewriter', description: 'Never lose content editor & Markdown'} | ||||
| ]" /> | ||||
| 
 | ||||
| * [Partico](https://partiko.app/) - Partiko is a censorship free social network. | ||||
| * [FairNote](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote) - simple and intuitive notepad app. It gives you speed and efficiency when you write notes, to-do lists, e-mails, or jot down quick ideas. | ||||
| * [Boxcryptor](https://www.boxcryptor.com) - A software that adds AES-256 and RSA encryption to Dropbox, Google Drive, OneDrive and many other clouds. | ||||
| * [Senstone Portable Voice Assistant](https://play.google.com/store/apps/details?id=com.senstone) - Senstone is a tiny wearable personal assistant powered by this App. It lets you capture your ideas, notes and reminders handsfree without pulling out your phone.  | ||||
| 
 | ||||
| 
 | ||||
| <u>Extension/plugins</u>: | ||||
| 
 | ||||
|  | ||||
| @ -5,7 +5,7 @@ These are _configurable_ properties: | ||||
| * `AsyncDrawableLoader` (back here since <Badge text="4.0.0" />) | ||||
| * `SyntaxHighlight` | ||||
| * `LinkResolver` (since <Badge text="4.0.0" />, before — `LinkSpan.Resolver`) | ||||
| * `UrlProcessor` | ||||
| * `ImageDestinationProcessor` (since <Badge text="4.4.0" />, before — `UrlProcessor`) | ||||
| * `ImageSizeResolver` | ||||
| 
 | ||||
| :::tip | ||||
| @ -36,10 +36,11 @@ final Markwon markwon = Markwon.builder(context) | ||||
|         .build(); | ||||
| ``` | ||||
| 
 | ||||
| Currently `Markwon` provides 3 implementations for loading images: | ||||
| Currently `Markwon` provides 4 implementations for loading images: | ||||
| * [markwon implementation](/docs/v4/image/) with SVG, GIF, data uri and android_assets support | ||||
| * [based on Picasso](/docs/v4/image-picasso/) | ||||
| * [based on Glide](/docs/v4/image-glide/) | ||||
| * [base on Coil](/docs/v4/image-coil/) | ||||
| 
 | ||||
| ## SyntaxHighlight | ||||
| 
 | ||||
| @ -87,32 +88,32 @@ if there is none registered. if you wish to register own instance of a `Movement | ||||
| apply it directly to a TextView or use [MovementMethodPlugin](/docs/v4/core/movement-method-plugin.md) | ||||
| ::: | ||||
| 
 | ||||
| ## UrlProcessor | ||||
| ## ImageDestinationProcessor | ||||
| 
 | ||||
| Process URLs in your markdown (for links and images). If not provided explicitly,  | ||||
| Process destinations (URLs) of images in your markdown. If not provided explicitly,  | ||||
| default **no-op** implementation will be used, which does not modify URLs (keeping them as-is). | ||||
| 
 | ||||
| `Markwon` provides 2 implementations of `UrlProcessor`: | ||||
| * `UrlProcessorRelativeToAbsolute` | ||||
| * `UrlProcessorAndroidAssets` | ||||
| * `ImageDestinationProcessorRelativeToAbsolute` | ||||
| * `ImageDestinationProcessorAssets` | ||||
| 
 | ||||
| ### UrlProcessorRelativeToAbsolute | ||||
| ### ImageDestinationProcessorRelativeToAbsolute | ||||
| 
 | ||||
| `UrlProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is | ||||
| defined like this: `` and `UrlProcessorRelativeToAbsolute` | ||||
| `ImageDestinationProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is | ||||
| defined like this: `` and `ImageDestinationProcessorRelativeToAbsolute` | ||||
| is created with `https://github.com/noties/Markwon/raw/master/` as the base:  | ||||
| `new UrlProcessorRelativeToAbsolute("https://github.com/noties/Markwon/raw/master/")`, | ||||
| `new ImageDestinationProcessorRelativeToAbsolute("https://github.com/noties/Markwon/raw/master/")`, | ||||
| then final image will have `https://github.com/noties/Markwon/raw/master/art/image.JPG` | ||||
| as the destination. | ||||
| 
 | ||||
| ### UrlProcessorAndroidAssets | ||||
| ### ImageDestinationProcessorAssets | ||||
| 
 | ||||
| `UrlProcessorAndroidAssets` can be used to make processed links to point to Android assets folder. | ||||
| `ImageDestinationProcessorAssets` can be used to make processed destinations to point to Android assets folder. | ||||
| So an image: `` will have `file:///android_asset/art/image.JPG` as the | ||||
| destination. | ||||
| 
 | ||||
| :::tip | ||||
| Please note that `UrlProcessorAndroidAssets` will process only URLs that have no `scheme` information, | ||||
| Please note that `ImageDestinationProcessorAssets` will process only URLs that have no `scheme` information, | ||||
| so a `./art/image.png` will become `file:///android_asset/art/image.JPG` whilst `https://so.me/where.png` | ||||
| will be kept as-is. | ||||
| ::: | ||||
|  | ||||
| @ -8,7 +8,7 @@ android.enableJetifier=true | ||||
| android.enableBuildCache=true | ||||
| android.buildCacheDir=build/pre-dex-cache | ||||
| 
 | ||||
| VERSION_NAME=4.3.1 | ||||
| VERSION_NAME=4.4.0 | ||||
| 
 | ||||
| GROUP=io.noties.markwon | ||||
| POM_DESCRIPTION=Markwon markdown for Android | ||||
|  | ||||
| @ -192,6 +192,17 @@ public abstract class Markwon { | ||||
|         @NonNull | ||||
|         Builder usePlugins(@NonNull Iterable<? extends MarkwonPlugin> plugins); | ||||
| 
 | ||||
|         /** | ||||
|          * Control if small chunks of non-finished markdown sentences (for example, a single `*` character) | ||||
|          * should be displayed/rendered as raw input instead of an empty string. | ||||
|          * <p> | ||||
|          * Since 4.4.0 {@code true} by default, versions prior - {@code false} | ||||
|          * | ||||
|          * @since 4.4.0 | ||||
|          */ | ||||
|         @NonNull | ||||
|         Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty); | ||||
| 
 | ||||
|         @NonNull | ||||
|         Markwon build(); | ||||
|     } | ||||
|  | ||||
| @ -27,6 +27,9 @@ class MarkwonBuilderImpl implements Markwon.Builder { | ||||
| 
 | ||||
|     private Markwon.TextSetter textSetter; | ||||
| 
 | ||||
|     // @since 4.4.0 | ||||
|     private boolean fallbackToRawInputWhenEmpty = true; | ||||
| 
 | ||||
|     MarkwonBuilderImpl(@NonNull Context context) { | ||||
|         this.context = context; | ||||
|     } | ||||
| @ -71,6 +74,13 @@ class MarkwonBuilderImpl implements Markwon.Builder { | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Markwon.Builder fallbackToRawInputWhenEmpty(boolean fallbackToRawInputWhenEmpty) { | ||||
|         this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty; | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Markwon build() { | ||||
| @ -114,7 +124,8 @@ class MarkwonBuilderImpl implements Markwon.Builder { | ||||
|                 parserBuilder.build(), | ||||
|                 visitorFactory, | ||||
|                 configuration, | ||||
|                 Collections.unmodifiableList(plugins) | ||||
|                 Collections.unmodifiableList(plugins), | ||||
|                 fallbackToRawInputWhenEmpty | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -6,15 +6,13 @@ import io.noties.markwon.core.MarkwonTheme; | ||||
| import io.noties.markwon.image.AsyncDrawableLoader; | ||||
| import io.noties.markwon.image.ImageSizeResolver; | ||||
| import io.noties.markwon.image.ImageSizeResolverDef; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessor; | ||||
| import io.noties.markwon.syntax.SyntaxHighlight; | ||||
| import io.noties.markwon.syntax.SyntaxHighlightNoOp; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessor; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessorNoOp; | ||||
| 
 | ||||
| /** | ||||
|  * since 3.0.0 renamed `SpannableConfiguration` -> `MarkwonConfiguration` | ||||
|  */ | ||||
| @SuppressWarnings("WeakerAccess") | ||||
| public class MarkwonConfiguration { | ||||
| 
 | ||||
|     @NonNull | ||||
| @ -26,7 +24,8 @@ public class MarkwonConfiguration { | ||||
|     private final AsyncDrawableLoader asyncDrawableLoader; | ||||
|     private final SyntaxHighlight syntaxHighlight; | ||||
|     private final LinkResolver linkResolver; | ||||
|     private final UrlProcessor urlProcessor; | ||||
|     // @since 4.4.0 | ||||
|     private final ImageDestinationProcessor imageDestinationProcessor; | ||||
|     private final ImageSizeResolver imageSizeResolver; | ||||
| 
 | ||||
|     // @since 3.0.0 | ||||
| @ -37,7 +36,7 @@ public class MarkwonConfiguration { | ||||
|         this.asyncDrawableLoader = builder.asyncDrawableLoader; | ||||
|         this.syntaxHighlight = builder.syntaxHighlight; | ||||
|         this.linkResolver = builder.linkResolver; | ||||
|         this.urlProcessor = builder.urlProcessor; | ||||
|         this.imageDestinationProcessor = builder.imageDestinationProcessor; | ||||
|         this.imageSizeResolver = builder.imageSizeResolver; | ||||
|         this.spansFactory = builder.spansFactory; | ||||
|     } | ||||
| @ -62,9 +61,12 @@ public class MarkwonConfiguration { | ||||
|         return linkResolver; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @since 4.4.0 | ||||
|      */ | ||||
|     @NonNull | ||||
|     public UrlProcessor urlProcessor() { | ||||
|         return urlProcessor; | ||||
|     public ImageDestinationProcessor imageDestinationProcessor() { | ||||
|         return imageDestinationProcessor; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
| @ -87,7 +89,8 @@ public class MarkwonConfiguration { | ||||
|         private AsyncDrawableLoader asyncDrawableLoader; | ||||
|         private SyntaxHighlight syntaxHighlight; | ||||
|         private LinkResolver linkResolver; | ||||
|         private UrlProcessor urlProcessor; | ||||
|         // @since 4.4.0 | ||||
|         private ImageDestinationProcessor imageDestinationProcessor; | ||||
|         private ImageSizeResolver imageSizeResolver; | ||||
|         private MarkwonSpansFactory spansFactory; | ||||
| 
 | ||||
| @ -115,9 +118,12 @@ public class MarkwonConfiguration { | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
|         /** | ||||
|          * @since 4.4.0 | ||||
|          */ | ||||
|         @NonNull | ||||
|         public Builder urlProcessor(@NonNull UrlProcessor urlProcessor) { | ||||
|             this.urlProcessor = urlProcessor; | ||||
|         public Builder imageDestinationProcessor(@NonNull ImageDestinationProcessor imageDestinationProcessor) { | ||||
|             this.imageDestinationProcessor = imageDestinationProcessor; | ||||
|             return this; | ||||
|         } | ||||
| 
 | ||||
| @ -151,8 +157,9 @@ public class MarkwonConfiguration { | ||||
|                 linkResolver = new LinkResolverDef(); | ||||
|             } | ||||
| 
 | ||||
|             if (urlProcessor == null) { | ||||
|                 urlProcessor = new UrlProcessorNoOp(); | ||||
|             // @since 4.4.0 | ||||
|             if (imageDestinationProcessor == null) { | ||||
|                 imageDestinationProcessor = ImageDestinationProcessor.noOp(); | ||||
|             } | ||||
| 
 | ||||
|             if (imageSizeResolver == null) { | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| package io.noties.markwon; | ||||
| 
 | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextUtils; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| @ -28,19 +30,25 @@ class MarkwonImpl extends Markwon { | ||||
|     @Nullable | ||||
|     private final TextSetter textSetter; | ||||
| 
 | ||||
|     // @since 4.4.0 | ||||
|     private final boolean fallbackToRawInputWhenEmpty; | ||||
| 
 | ||||
|     MarkwonImpl( | ||||
|             @NonNull TextView.BufferType bufferType, | ||||
|             @Nullable TextSetter textSetter, | ||||
|             @NonNull Parser parser, | ||||
|             @NonNull MarkwonVisitorFactory visitorFactory, | ||||
|             @NonNull MarkwonConfiguration configuration, | ||||
|             @NonNull List<MarkwonPlugin> plugins) { | ||||
|             @NonNull List<MarkwonPlugin> plugins, | ||||
|             boolean fallbackToRawInputWhenEmpty | ||||
|     ) { | ||||
|         this.bufferType = bufferType; | ||||
|         this.textSetter = textSetter; | ||||
|         this.parser = parser; | ||||
|         this.visitorFactory = visitorFactory; | ||||
|         this.configuration = configuration; | ||||
|         this.plugins = plugins; | ||||
|         this.fallbackToRawInputWhenEmpty = fallbackToRawInputWhenEmpty; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
| @ -86,7 +94,18 @@ class MarkwonImpl extends Markwon { | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Spanned toMarkdown(@NonNull String input) { | ||||
|         return render(parse(input)); | ||||
|         final Spanned spanned = render(parse(input)); | ||||
| 
 | ||||
|         // @since 4.4.0 | ||||
|         // if spanned is empty, we are configured to use raw input and input is not empty | ||||
|         if (TextUtils.isEmpty(spanned) | ||||
|                 && fallbackToRawInputWhenEmpty | ||||
|                 && !TextUtils.isEmpty(input)) { | ||||
|             // let's use SpannableStringBuilder in order to keep backward-compatibility | ||||
|             return new SpannableStringBuilder(input); | ||||
|         } | ||||
| 
 | ||||
|         return spanned; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| package io.noties.markwon.core; | ||||
| 
 | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.widget.TextView; | ||||
| @ -8,6 +9,7 @@ import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.annotation.VisibleForTesting; | ||||
| 
 | ||||
| import org.commonmark.node.Block; | ||||
| import org.commonmark.node.BlockQuote; | ||||
| import org.commonmark.node.BulletList; | ||||
| import org.commonmark.node.Code; | ||||
| @ -15,6 +17,7 @@ import org.commonmark.node.Emphasis; | ||||
| import org.commonmark.node.FencedCodeBlock; | ||||
| import org.commonmark.node.HardLineBreak; | ||||
| import org.commonmark.node.Heading; | ||||
| import org.commonmark.node.HtmlBlock; | ||||
| import org.commonmark.node.Image; | ||||
| import org.commonmark.node.IndentedCodeBlock; | ||||
| import org.commonmark.node.Link; | ||||
| @ -29,7 +32,10 @@ import org.commonmark.node.Text; | ||||
| import org.commonmark.node.ThematicBreak; | ||||
| 
 | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.HashSet; | ||||
| import java.util.List; | ||||
| import java.util.Set; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.MarkwonConfiguration; | ||||
| @ -48,6 +54,7 @@ import io.noties.markwon.core.factory.ListItemSpanFactory; | ||||
| import io.noties.markwon.core.factory.StrongEmphasisSpanFactory; | ||||
| import io.noties.markwon.core.factory.ThematicBreakSpanFactory; | ||||
| import io.noties.markwon.core.spans.OrderedListItemSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| import io.noties.markwon.image.ImageProps; | ||||
| 
 | ||||
| /** | ||||
| @ -88,6 +95,23 @@ public class CorePlugin extends AbstractMarkwonPlugin { | ||||
|         return new CorePlugin(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @return a set with enabled by default block types | ||||
|      * @since 4.4.0 | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static Set<Class<? extends Block>> enabledBlockTypes() { | ||||
|         return new HashSet<>(Arrays.asList( | ||||
|                 BlockQuote.class, | ||||
|                 Heading.class, | ||||
|                 FencedCodeBlock.class, | ||||
|                 HtmlBlock.class, | ||||
|                 ThematicBreak.class, | ||||
|                 ListBlock.class, | ||||
|                 IndentedCodeBlock.class | ||||
|         )); | ||||
|     } | ||||
| 
 | ||||
|     // @since 4.0.0 | ||||
|     private final List<OnTextAddedListener> onTextAddedListeners = new ArrayList<>(0); | ||||
| 
 | ||||
| @ -150,6 +174,13 @@ public class CorePlugin extends AbstractMarkwonPlugin { | ||||
|     @Override | ||||
|     public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { | ||||
|         OrderedListItemSpan.measure(textView, markdown); | ||||
| 
 | ||||
|         // @since 4.4.0 | ||||
|         // we do not break API compatibility, instead we introduce the `instance of` check | ||||
|         if (markdown instanceof Spannable) { | ||||
|             final Spannable spannable = (Spannable) markdown; | ||||
|             TextViewSpan.applyTo(spannable, textView); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -289,7 +320,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { | ||||
|                 final boolean link = parent instanceof Link; | ||||
| 
 | ||||
|                 final String destination = configuration | ||||
|                         .urlProcessor() | ||||
|                         .imageDestinationProcessor() | ||||
|                         .process(image.getDestination()); | ||||
| 
 | ||||
|                 final RenderProps props = visitor.renderProps(); | ||||
| @ -493,8 +524,7 @@ public class CorePlugin extends AbstractMarkwonPlugin { | ||||
|                 final int length = visitor.length(); | ||||
|                 visitor.visitChildren(link); | ||||
| 
 | ||||
|                 final MarkwonConfiguration configuration = visitor.configuration(); | ||||
|                 final String destination = configuration.urlProcessor().process(link.getDestination()); | ||||
|                 final String destination = link.getDestination(); | ||||
| 
 | ||||
|                 CoreProps.LINK_DESTINATION.set(visitor.renderProps(), destination); | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,70 @@ | ||||
| package io.noties.markwon.core.spans; | ||||
| 
 | ||||
| import android.text.Layout; | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.lang.ref.WeakReference; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public class TextLayoutSpan { | ||||
| 
 | ||||
|     /** | ||||
|      * @see #applyTo(Spannable, Layout) | ||||
|      */ | ||||
|     @Nullable | ||||
|     public static Layout layoutOf(@NonNull CharSequence cs) { | ||||
|         if (cs instanceof Spanned) { | ||||
|             return layoutOf((Spanned) cs); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static Layout layoutOf(@NonNull Spanned spanned) { | ||||
|         final TextLayoutSpan[] spans = spanned.getSpans( | ||||
|                 0, | ||||
|                 spanned.length(), | ||||
|                 TextLayoutSpan.class | ||||
|         ); | ||||
|         return spans != null && spans.length > 0 | ||||
|                 ? spans[0].layout() | ||||
|                 : null; | ||||
|     } | ||||
| 
 | ||||
|     public static void applyTo(@NonNull Spannable spannable, @NonNull Layout layout) { | ||||
| 
 | ||||
|         // remove all current ones (only one should be present) | ||||
|         final TextLayoutSpan[] spans = spannable.getSpans(0, spannable.length(), TextLayoutSpan.class); | ||||
|         if (spans != null) { | ||||
|             for (TextLayoutSpan span : spans) { | ||||
|                 spannable.removeSpan(span); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         final TextLayoutSpan span = new TextLayoutSpan(layout); | ||||
|         spannable.setSpan( | ||||
|                 span, | ||||
|                 0, | ||||
|                 spannable.length(), | ||||
|                 Spanned.SPAN_INCLUSIVE_INCLUSIVE | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private final WeakReference<Layout> reference; | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     TextLayoutSpan(@NonNull Layout layout) { | ||||
|         this.reference = new WeakReference<>(layout); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public Layout layout() { | ||||
|         return reference.get(); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,64 @@ | ||||
| package io.noties.markwon.core.spans; | ||||
| 
 | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.lang.ref.WeakReference; | ||||
| 
 | ||||
| /** | ||||
|  * A special span that allows to obtain {@code TextView} in which spans are displayed | ||||
|  * | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public class TextViewSpan { | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static TextView textViewOf(@NonNull CharSequence cs) { | ||||
|         if (cs instanceof Spanned) { | ||||
|             return textViewOf((Spanned) cs); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public static TextView textViewOf(@NonNull Spanned spanned) { | ||||
|         final TextViewSpan[] spans = spanned.getSpans(0, spanned.length(), TextViewSpan.class); | ||||
|         return spans != null && spans.length > 0 | ||||
|                 ? spans[0].textView() | ||||
|                 : null; | ||||
|     } | ||||
| 
 | ||||
|     public static void applyTo(@NonNull Spannable spannable, @NonNull TextView textView) { | ||||
| 
 | ||||
|         final TextViewSpan[] spans = spannable.getSpans(0, spannable.length(), TextViewSpan.class); | ||||
|         if (spans != null) { | ||||
|             for (TextViewSpan span : spans) { | ||||
|                 spannable.removeSpan(span); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         final TextViewSpan span = new TextViewSpan(textView); | ||||
|         // `SPAN_INCLUSIVE_INCLUSIVE` to persist in case of possible text change (deletion, etc) | ||||
|         spannable.setSpan( | ||||
|                 span, | ||||
|                 0, | ||||
|                 spannable.length(), | ||||
|                 Spanned.SPAN_INCLUSIVE_INCLUSIVE | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private final WeakReference<TextView> reference; | ||||
| 
 | ||||
|     public TextViewSpan(@NonNull TextView textView) { | ||||
|         this.reference = new WeakReference<>(textView); | ||||
|     } | ||||
| 
 | ||||
|     @Nullable | ||||
|     public TextView textView() { | ||||
|         return reference.get(); | ||||
|     } | ||||
| } | ||||
| @ -14,6 +14,7 @@ import java.lang.annotation.Retention; | ||||
| import java.lang.annotation.RetentionPolicy; | ||||
| 
 | ||||
| import io.noties.markwon.core.MarkwonTheme; | ||||
| import io.noties.markwon.utils.SpanUtils; | ||||
| 
 | ||||
| @SuppressWarnings("WeakerAccess") | ||||
| public class AsyncDrawableSpan extends ReplacementSpan { | ||||
| @ -99,7 +100,11 @@ public class AsyncDrawableSpan extends ReplacementSpan { | ||||
|             int bottom, | ||||
|             @NonNull Paint paint) { | ||||
| 
 | ||||
|         drawable.initWithKnownDimensions(canvas.getWidth(), paint.getTextSize()); | ||||
|         // @since 4.4.0 use SpanUtils instead of `canvas.getWidth` | ||||
|         drawable.initWithKnownDimensions( | ||||
|                 SpanUtils.width(canvas, text), | ||||
|                 paint.getTextSize() | ||||
|         ); | ||||
| 
 | ||||
|         final AsyncDrawable drawable = this.drawable; | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,27 @@ | ||||
| package io.noties.markwon.image.destination; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| /** | ||||
|  * Process destination of image nodes | ||||
|  * | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public abstract class ImageDestinationProcessor { | ||||
|     @NonNull | ||||
|     public abstract String process(@NonNull String destination); | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ImageDestinationProcessor noOp() { | ||||
|         return new NoOp(); | ||||
|     } | ||||
| 
 | ||||
|     private static class NoOp extends ImageDestinationProcessor { | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public String process(@NonNull String destination) { | ||||
|             return destination; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,59 @@ | ||||
| package io.noties.markwon.image.destination; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| import android.text.TextUtils; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| /** | ||||
|  * {@link ImageDestinationProcessor} that treats all destinations <strong>without scheme</strong> | ||||
|  * information as pointing to the {@code assets} folder of an application. Please note that this | ||||
|  * processor only adds required {@code file:///android_asset/} prefix to destinations and | ||||
|  * actual image loading must take that into account (implement this functionality). | ||||
|  * <p> | ||||
|  * {@code FileSchemeHandler} from the {@code image} module supports asset images when created with | ||||
|  * {@code createWithAssets} factory method | ||||
|  * | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public class ImageDestinationProcessorAssets extends ImageDestinationProcessor { | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ImageDestinationProcessorAssets create(@Nullable ImageDestinationProcessor parent) { | ||||
|         return new ImageDestinationProcessorAssets(parent); | ||||
|     } | ||||
| 
 | ||||
|     static final String MOCK = "https://android.asset/"; | ||||
|     static final String BASE = "file:///android_asset/"; | ||||
| 
 | ||||
|     private final ImageDestinationProcessorRelativeToAbsolute assetsProcessor | ||||
|             = new ImageDestinationProcessorRelativeToAbsolute(MOCK); | ||||
| 
 | ||||
|     private final ImageDestinationProcessor processor; | ||||
| 
 | ||||
|     public ImageDestinationProcessorAssets() { | ||||
|         this(null); | ||||
|     } | ||||
| 
 | ||||
|     public ImageDestinationProcessorAssets(@Nullable ImageDestinationProcessor parent) { | ||||
|         this.processor = parent; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public String process(@NonNull String destination) { | ||||
|         final String out; | ||||
|         final Uri uri = Uri.parse(destination); | ||||
|         if (TextUtils.isEmpty(uri.getScheme())) { | ||||
|             out = assetsProcessor.process(destination).replace(MOCK, BASE); | ||||
|         } else { | ||||
|             if (processor != null) { | ||||
|                 out = processor.process(destination); | ||||
|             } else { | ||||
|                 out = destination; | ||||
|             } | ||||
|         } | ||||
|         return out; | ||||
|     } | ||||
| } | ||||
| @ -1,4 +1,4 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| package io.noties.markwon.image.destination; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| @ -6,15 +6,30 @@ import androidx.annotation.Nullable; | ||||
| import java.net.MalformedURLException; | ||||
| import java.net.URL; | ||||
| 
 | ||||
| @SuppressWarnings("WeakerAccess") | ||||
| public class UrlProcessorRelativeToAbsolute implements UrlProcessor { | ||||
| /** | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public class ImageDestinationProcessorRelativeToAbsolute extends ImageDestinationProcessor { | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ImageDestinationProcessorRelativeToAbsolute create(@NonNull String base) { | ||||
|         return new ImageDestinationProcessorRelativeToAbsolute(base); | ||||
|     } | ||||
| 
 | ||||
|     public static ImageDestinationProcessorRelativeToAbsolute create(@NonNull URL base) { | ||||
|         return new ImageDestinationProcessorRelativeToAbsolute(base); | ||||
|     } | ||||
| 
 | ||||
|     private final URL base; | ||||
| 
 | ||||
|     public UrlProcessorRelativeToAbsolute(@NonNull String base) { | ||||
|     public ImageDestinationProcessorRelativeToAbsolute(@NonNull String base) { | ||||
|         this.base = obtain(base); | ||||
|     } | ||||
| 
 | ||||
|     public ImageDestinationProcessorRelativeToAbsolute(@NonNull URL base) { | ||||
|         this.base = base; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public String process(@NonNull String destination) { | ||||
| @ -1,8 +0,0 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| public interface UrlProcessor { | ||||
|     @NonNull | ||||
|     String process(@NonNull String destination); | ||||
| } | ||||
| @ -1,49 +0,0 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| 
 | ||||
| import android.net.Uri; | ||||
| import android.text.TextUtils; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| /** | ||||
|  * Processor that will <em>assume</em> that an URL without scheme points to android assets folder. | ||||
|  * URL with a scheme will be processed by {@link #processor} (if it is specified) or returned `as-is`. | ||||
|  */ | ||||
| @SuppressWarnings({"unused", "WeakerAccess"}) | ||||
| public class UrlProcessorAndroidAssets implements UrlProcessor { | ||||
| 
 | ||||
| 
 | ||||
|     static final String MOCK = "https://android.asset/"; | ||||
|     static final String BASE = "file:///android_asset/"; | ||||
| 
 | ||||
|     private final UrlProcessorRelativeToAbsolute assetsProcessor | ||||
|             = new UrlProcessorRelativeToAbsolute(MOCK); | ||||
| 
 | ||||
|     private final UrlProcessor processor; | ||||
| 
 | ||||
|     public UrlProcessorAndroidAssets() { | ||||
|         this(null); | ||||
|     } | ||||
| 
 | ||||
|     public UrlProcessorAndroidAssets(@Nullable UrlProcessor parent) { | ||||
|         this.processor = parent; | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public String process(@NonNull String destination) { | ||||
|         final String out; | ||||
|         final Uri uri = Uri.parse(destination); | ||||
|         if (TextUtils.isEmpty(uri.getScheme())) { | ||||
|             out = assetsProcessor.process(destination).replace(MOCK, BASE); | ||||
|         } else { | ||||
|             if (processor != null) { | ||||
|                 out = processor.process(destination); | ||||
|             } else { | ||||
|                 out = destination; | ||||
|             } | ||||
|         } | ||||
|         return out; | ||||
|     } | ||||
| } | ||||
| @ -1,11 +0,0 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| public class UrlProcessorNoOp implements UrlProcessor { | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public String process(@NonNull String destination) { | ||||
|         return destination; | ||||
|     } | ||||
| } | ||||
| @ -18,6 +18,7 @@ public class Dip { | ||||
| 
 | ||||
|     private final float density; | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     public Dip(float density) { | ||||
|         this.density = density; | ||||
|     } | ||||
|  | ||||
| @ -12,6 +12,7 @@ import java.lang.reflect.Method; | ||||
| import java.lang.reflect.Proxy; | ||||
| 
 | ||||
| // utility class to print parsed Nodes hierarchy | ||||
| @SuppressWarnings({"unused", "WeakerAccess"}) | ||||
| public abstract class DumpNodes { | ||||
| 
 | ||||
|     public interface NodeProcessor { | ||||
|  | ||||
| @ -0,0 +1,72 @@ | ||||
| package io.noties.markwon.utils; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public abstract class LayoutUtils { | ||||
| 
 | ||||
|     private static final float DEFAULT_EXTRA = 0F; | ||||
|     private static final float DEFAULT_MULTIPLIER = 1F; | ||||
| 
 | ||||
|     public static int getLineBottomWithoutPaddingAndSpacing( | ||||
|             @NonNull Layout layout, | ||||
|             int line | ||||
|     ) { | ||||
| 
 | ||||
|         final int bottom = layout.getLineBottom(line); | ||||
|         final boolean lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | ||||
|         final boolean isSpanLastLine = line == (layout.getLineCount() - 1); | ||||
| 
 | ||||
|         final int lineBottom; | ||||
|         final float lineSpacingExtra = layout.getSpacingAdd(); | ||||
|         final float lineSpacingMultiplier = layout.getSpacingMultiplier(); | ||||
| 
 | ||||
|         // simplified check | ||||
|         final boolean hasLineSpacing = lineSpacingExtra != DEFAULT_EXTRA | ||||
|                 || lineSpacingMultiplier != DEFAULT_MULTIPLIER; | ||||
| 
 | ||||
|         if (!hasLineSpacing | ||||
|                 || (isSpanLastLine && lastLineSpacingNotAdded)) { | ||||
|             lineBottom = bottom; | ||||
|         } else { | ||||
|             final float extra; | ||||
|             if (Float.compare(DEFAULT_MULTIPLIER, lineSpacingMultiplier) != 0) { | ||||
|                 final int lineHeight = getLineHeight(layout, line); | ||||
|                 extra = lineHeight - | ||||
|                         ((lineHeight - lineSpacingExtra) / lineSpacingMultiplier); | ||||
|             } else { | ||||
|                 extra = lineSpacingExtra; | ||||
|             } | ||||
|             lineBottom = (int) (bottom - extra + .5F); | ||||
|         } | ||||
| 
 | ||||
|         // check if it is the last line that span is occupying **and** that this line is the last | ||||
|         //  one in TextView | ||||
|         if (isSpanLastLine | ||||
|                 && (line == layout.getLineCount() - 1)) { | ||||
|             return lineBottom - layout.getBottomPadding(); | ||||
|         } | ||||
| 
 | ||||
|         return lineBottom; | ||||
|     } | ||||
| 
 | ||||
|     public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) { | ||||
|         final int top = layout.getLineTop(line); | ||||
|         if (line == 0) { | ||||
|             return top - layout.getTopPadding(); | ||||
|         } | ||||
|         return top; | ||||
|     } | ||||
| 
 | ||||
|     public static int getLineHeight(@NonNull Layout layout, int line) { | ||||
|         return layout.getLineTop(line + 1) - layout.getLineTop(line); | ||||
|     } | ||||
| 
 | ||||
|     private LayoutUtils() { | ||||
|     } | ||||
| } | ||||
| @ -4,7 +4,6 @@ import android.text.Spanned; | ||||
| 
 | ||||
| public abstract class LeadingMarginUtils { | ||||
| 
 | ||||
|     @SuppressWarnings("BooleanMethodIsAlwaysInverted") | ||||
|     public static boolean selfStart(int start, CharSequence text, Object span) { | ||||
|         return text instanceof Spanned && ((Spanned) text).getSpanStart(span) == start; | ||||
|     } | ||||
|  | ||||
| @ -0,0 +1,42 @@ | ||||
| package io.noties.markwon.utils; | ||||
| 
 | ||||
| import android.graphics.Canvas; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| 
 | ||||
| /** | ||||
|  * @since 4.4.0 | ||||
|  */ | ||||
| public abstract class SpanUtils { | ||||
| 
 | ||||
|     public static int width(@NonNull Canvas canvas, @NonNull CharSequence cs) { | ||||
|         // Layout | ||||
|         // TextView | ||||
|         // canvas | ||||
| 
 | ||||
|         if (cs instanceof Spanned) { | ||||
|             final Spanned spanned = (Spanned) cs; | ||||
| 
 | ||||
|             // if we are displayed with layout information -> use it | ||||
|             final Layout layout = TextLayoutSpan.layoutOf(spanned); | ||||
|             if (layout != null) { | ||||
|                 return layout.getWidth(); | ||||
|             } | ||||
| 
 | ||||
|             // if we have TextView -> obtain width from it (exclude padding) | ||||
|             final TextView textView = TextViewSpan.textViewOf(spanned); | ||||
|             if (textView != null) { | ||||
|                 return textView.getWidth() - textView.getPaddingLeft() - textView.getPaddingRight(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // else just use canvas width | ||||
|         return canvas.getWidth(); | ||||
|     } | ||||
| } | ||||
| @ -1,6 +1,7 @@ | ||||
| package io.noties.markwon; | ||||
| 
 | ||||
| import android.text.Spanned; | ||||
| import android.text.TextUtils; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import org.commonmark.node.Node; | ||||
| @ -50,7 +51,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
|                 Collections.singletonList(plugin), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         impl.parse("whatever"); | ||||
| 
 | ||||
| @ -74,7 +77,9 @@ public class MarkwonImplTest { | ||||
|                 parser, | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Arrays.asList(first, second)); | ||||
|                 Arrays.asList(first, second), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         impl.parse("zero"); | ||||
| 
 | ||||
| @ -102,7 +107,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
|                 Collections.singletonList(plugin), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         when(visitorFactory.create()).thenReturn(visitor); | ||||
|         when(visitor.builder()).thenReturn(builder); | ||||
| @ -149,7 +156,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.<MarkwonPlugin>emptyList()); | ||||
|                 Collections.<MarkwonPlugin>emptyList(), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         impl.render(mock(Node.class)); | ||||
| 
 | ||||
| @ -185,7 +194,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 visitorFactory, | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
|                 Collections.singletonList(plugin), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         final AtomicBoolean flag = new AtomicBoolean(false); | ||||
|         final Node node = mock(Node.class); | ||||
| @ -224,7 +235,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
|                 Collections.singletonList(plugin), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         final TextView textView = mock(TextView.class); | ||||
|         final AtomicBoolean flag = new AtomicBoolean(false); | ||||
| @ -272,7 +285,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
|                 plugins, | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         assertTrue("First", impl.hasPlugin(First.class)); | ||||
|         assertFalse("Second", impl.hasPlugin(Second.class)); | ||||
| @ -295,7 +310,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.singletonList(plugin)); | ||||
|                 Collections.singletonList(plugin), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         final TextView textView = mock(TextView.class); | ||||
|         final Spanned spanned = mock(Spanned.class); | ||||
| @ -339,7 +356,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
|                 plugins, | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         // should be returned | ||||
|         assertNotNull(impl.requirePlugin(MarkwonPlugin.class)); | ||||
| @ -370,7 +389,9 @@ public class MarkwonImplTest { | ||||
|                 mock(Parser.class), | ||||
|                 mock(MarkwonVisitorFactory.class), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 plugins); | ||||
|                 plugins, | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         final List<? extends MarkwonPlugin> list = impl.getPlugins(); | ||||
| 
 | ||||
| @ -385,4 +406,42 @@ public class MarkwonImplTest { | ||||
|             assertTrue(e.getMessage(), true); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void fallback_to_raw() { | ||||
|         final String md = "*"; | ||||
| 
 | ||||
|         final MarkwonImpl impl = new MarkwonImpl( | ||||
|                 TextView.BufferType.SPANNABLE, | ||||
|                 null, | ||||
|                 mock(Parser.class, RETURNS_MOCKS), | ||||
|                 // it must be sufficient to just return mocks and thus empty rendering result | ||||
|                 mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.<MarkwonPlugin>emptyList(), | ||||
|                 true | ||||
|         ); | ||||
| 
 | ||||
|         final Spanned spanned = impl.toMarkdown(md); | ||||
|         assertEquals(md, spanned.toString()); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void fallback_to_raw_false() { | ||||
|         final String md = "*"; | ||||
| 
 | ||||
|         final MarkwonImpl impl = new MarkwonImpl( | ||||
|                 TextView.BufferType.SPANNABLE, | ||||
|                 null, | ||||
|                 mock(Parser.class, RETURNS_MOCKS), | ||||
|                 // it must be sufficient to just return mocks and thus empty rendering result | ||||
|                 mock(MarkwonVisitorFactory.class, RETURNS_MOCKS), | ||||
|                 mock(MarkwonConfiguration.class), | ||||
|                 Collections.<MarkwonPlugin>emptyList(), | ||||
|                 false | ||||
|         ); | ||||
| 
 | ||||
|         final Spanned spanned = impl.toMarkdown(md); | ||||
|         assertTrue(spanned.toString(), TextUtils.isEmpty(spanned)); | ||||
|     } | ||||
| } | ||||
| @ -1,4 +1,4 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| package io.noties.markwon.image.destination; | ||||
| 
 | ||||
| import org.junit.Before; | ||||
| import org.junit.Test; | ||||
| @ -6,18 +6,18 @@ import org.junit.runner.RunWith; | ||||
| import org.robolectric.RobolectricTestRunner; | ||||
| import org.robolectric.annotation.Config; | ||||
| 
 | ||||
| import static io.noties.markwon.image.destination.ImageDestinationProcessorAssets.BASE; | ||||
| import static org.junit.Assert.assertEquals; | ||||
| import static io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets.BASE; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class UrlProcessorAndroidAssetsTest { | ||||
| public class ImageDestinationProcessorAssetsTest { | ||||
| 
 | ||||
|     private UrlProcessorAndroidAssets processor; | ||||
|     private ImageDestinationProcessorAssets processor; | ||||
| 
 | ||||
|     @Before | ||||
|     public void before() { | ||||
|         processor = new UrlProcessorAndroidAssets(); | ||||
|         processor = new ImageDestinationProcessorAssets(); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
| @ -1,4 +1,4 @@ | ||||
| package io.noties.markwon.urlprocessor; | ||||
| package io.noties.markwon.image.destination; | ||||
| 
 | ||||
| import org.junit.Test; | ||||
| import org.junit.runner.RunWith; | ||||
| @ -9,39 +9,39 @@ import static org.junit.Assert.*; | ||||
| 
 | ||||
| @RunWith(RobolectricTestRunner.class) | ||||
| @Config(manifest = Config.NONE) | ||||
| public class UrlProcessorRelativeToAbsoluteTest { | ||||
| public class ImageDestinationProcessorRelativeToAbsoluteTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void malformed_base_do_not_process() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("!@#$%^&*("); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("!@#$%^&*("); | ||||
|         final String destination = "../hey.there.html"; | ||||
|         assertEquals(destination, processor.process(destination)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void access_root() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("https://ro.ot/hello/"); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("https://ro.ot/hello/"); | ||||
|         final String url = "/index.html"; | ||||
|         assertEquals("https://ro.ot/index.html", processor.process(url)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void access_same_directory() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("https://ro.ot/hello/"); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("https://ro.ot/hello/"); | ||||
|         final String url = "./.htaccess"; | ||||
|         assertEquals("https://ro.ot/hello/.htaccess", processor.process(url)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void asset_directory_up() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/second/"); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/second/"); | ||||
|         final String url = "../cat.JPG"; | ||||
|         assertEquals("http://ro.ot/first/cat.JPG", processor.process(url)); | ||||
|     } | ||||
| 
 | ||||
|     @Test | ||||
|     public void change_directory_inside_destination() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/"); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/"); | ||||
|         final String url = "../first/../second/./thi.rd"; | ||||
|         assertEquals( | ||||
|                 "http://ro.ot/second/thi.rd", | ||||
| @ -51,7 +51,7 @@ public class UrlProcessorRelativeToAbsoluteTest { | ||||
| 
 | ||||
|     @Test | ||||
|     public void with_query_arguments() { | ||||
|         final UrlProcessorRelativeToAbsolute processor = new UrlProcessorRelativeToAbsolute("http://ro.ot/first/"); | ||||
|         final ImageDestinationProcessorRelativeToAbsolute processor = new ImageDestinationProcessorRelativeToAbsolute("http://ro.ot/first/"); | ||||
|         final String url = "../index.php?ROOT=1"; | ||||
|         assertEquals( | ||||
|                 "http://ro.ot/index.php?ROOT=1", | ||||
| @ -458,8 +458,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { | ||||
| 
 | ||||
|             final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) | ||||
|                     .textSize(theme.blockTextSize()) | ||||
|                     .align(theme.blockHorizontalAlignment()) | ||||
|                     .fitCanvas(theme.blockFitCanvas()); | ||||
|                     .align(theme.blockHorizontalAlignment()); | ||||
| 
 | ||||
|             if (backgroundProvider != null) { | ||||
|                 builder.background(backgroundProvider.provide()); | ||||
| @ -489,8 +488,7 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { | ||||
|             final int color = theme.inlineTextColor(); | ||||
| 
 | ||||
|             final JLatexMathDrawable.Builder builder = JLatexMathDrawable.builder(latex) | ||||
|                     .textSize(theme.inlineTextSize()) | ||||
|                     .fitCanvas(false); | ||||
|                     .textSize(theme.inlineTextSize()); | ||||
| 
 | ||||
|             if (backgroundProvider != null) { | ||||
|                 builder.background(backgroundProvider.provide()); | ||||
| @ -530,7 +528,20 @@ public class JLatexMathPlugin extends AbstractMarkwonPlugin { | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public Rect resolveImageSize(@NonNull AsyncDrawable drawable) { | ||||
|             return drawable.getResult().getBounds(); | ||||
| 
 | ||||
|             // @since 4.4.0 resolve inline size (scale down if exceed available width) | ||||
|             final Rect imageBounds = drawable.getResult().getBounds(); | ||||
|             final int canvasWidth = drawable.getLastKnownCanvasWidth(); | ||||
|             final int w = imageBounds.width(); | ||||
| 
 | ||||
|             if (w > canvasWidth) { | ||||
|                 // here we must scale it down (keeping the ratio) | ||||
|                 final float ratio = (float) w / imageBounds.height(); | ||||
|                 final int h = (int) (canvasWidth / ratio + .5F); | ||||
|                 return new Rect(0, 0, canvasWidth, h); | ||||
|             } | ||||
| 
 | ||||
|             return imageBounds; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -4,7 +4,10 @@ import android.annotation.SuppressLint; | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.Rect; | ||||
| import android.graphics.drawable.Drawable; | ||||
| import android.text.Layout; | ||||
| import android.text.Spannable; | ||||
| import android.text.SpannableString; | ||||
| import android.text.Spanned; | ||||
| import android.text.StaticLayout; | ||||
| import android.text.TextPaint; | ||||
| @ -20,7 +23,11 @@ import java.lang.annotation.RetentionPolicy; | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.image.AsyncDrawable; | ||||
| import io.noties.markwon.image.AsyncDrawableSpan; | ||||
| import io.noties.markwon.utils.LeadingMarginUtils; | ||||
| import io.noties.markwon.utils.SpanUtils; | ||||
| 
 | ||||
| public class TableRowSpan extends ReplacementSpan { | ||||
| 
 | ||||
| @ -67,7 +74,7 @@ public class TableRowSpan extends ReplacementSpan { | ||||
| 
 | ||||
|     private final TableTheme theme; | ||||
|     private final List<Cell> cells; | ||||
|     private final List<StaticLayout> layouts; | ||||
|     private final List<Layout> layouts; | ||||
|     private final TextPaint textPaint; | ||||
|     private final boolean header; | ||||
|     private final boolean odd; | ||||
| @ -108,7 +115,7 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|             if (fm != null) { | ||||
| 
 | ||||
|                 int max = 0; | ||||
|                 for (StaticLayout layout : layouts) { | ||||
|                 for (Layout layout : layouts) { | ||||
|                     final int height = layout.getHeight(); | ||||
|                     if (height > max) { | ||||
|                         max = height; | ||||
| @ -144,8 +151,9 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|             int bottom, | ||||
|             @NonNull Paint p) { | ||||
| 
 | ||||
|         if (recreateLayouts(canvas.getWidth())) { | ||||
|             width = canvas.getWidth(); | ||||
|         final int spanWidth = SpanUtils.width(canvas, text); | ||||
|         if (recreateLayouts(spanWidth)) { | ||||
|             width = spanWidth; | ||||
|             // @since 4.3.1 it's important to cast to TextPaint in order to display links, etc | ||||
|             if (p instanceof TextPaint) { | ||||
|                 // there must be a reason why this method receives Paint instead of TextPaint... | ||||
| @ -236,7 +244,7 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|         final int borderTop = isFirstTableRow ? borderWidth : 0; | ||||
|         final int borderBottom = bottom - top - borderWidth; | ||||
| 
 | ||||
|         StaticLayout layout; | ||||
|         Layout layout; | ||||
|         for (int i = 0; i < size; i++) { | ||||
|             layout = layouts.get(i); | ||||
|             final int save = canvas.save(); | ||||
| @ -293,20 +301,76 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|         final int w = (width / columns) - padding; | ||||
| 
 | ||||
|         this.layouts.clear(); | ||||
|         Cell cell; | ||||
|         StaticLayout layout; | ||||
| 
 | ||||
|         for (int i = 0, size = cells.size(); i < size; i++) { | ||||
|             cell = cells.get(i); | ||||
|             layout = new StaticLayout( | ||||
|                     cell.text, | ||||
|                     textPaint, | ||||
|                     w, | ||||
|                     alignment(cell.alignment), | ||||
|                     1.F, | ||||
|                     .0F, | ||||
|                     false | ||||
|             ); | ||||
|             layouts.add(layout); | ||||
|             makeLayout(i, w, cells.get(i)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private void makeLayout(final int index, final int width, @NonNull final Cell cell) { | ||||
| 
 | ||||
|         final Runnable recreate = new Runnable() { | ||||
|             @Override | ||||
|             public void run() { | ||||
|                 final Invalidator invalidator = TableRowSpan.this.invalidator; | ||||
|                 if (invalidator != null) { | ||||
|                     layouts.remove(index); | ||||
|                     makeLayout(index, width, cell); | ||||
|                     invalidator.invalidate(); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         final Spannable spannable; | ||||
| 
 | ||||
|         if (cell.text instanceof Spannable) { | ||||
|             spannable = (Spannable) cell.text; | ||||
|         } else { | ||||
|             spannable = new SpannableString(cell.text); | ||||
|         } | ||||
| 
 | ||||
|         final Layout layout = new StaticLayout( | ||||
|                 spannable, | ||||
|                 textPaint, | ||||
|                 width, | ||||
|                 alignment(cell.alignment), | ||||
|                 1.0F, | ||||
|                 0.0F, | ||||
|                 false | ||||
|         ); | ||||
| 
 | ||||
|         // @since 4.4.0 | ||||
|         TextLayoutSpan.applyTo(spannable, layout); | ||||
| 
 | ||||
|         // @since 4.4.0 | ||||
|         scheduleAsyncDrawables(spannable, recreate); | ||||
| 
 | ||||
|         layouts.add(index, layout); | ||||
|     } | ||||
| 
 | ||||
|     private void scheduleAsyncDrawables(@NonNull Spannable spannable, @NonNull final Runnable recreate) { | ||||
| 
 | ||||
|         final AsyncDrawableSpan[] spans = spannable.getSpans(0, spannable.length(), AsyncDrawableSpan.class); | ||||
|         if (spans != null | ||||
|                 && spans.length > 0) { | ||||
| 
 | ||||
|             for (AsyncDrawableSpan span : spans) { | ||||
| 
 | ||||
|                 final AsyncDrawable drawable = span.getDrawable(); | ||||
| 
 | ||||
|                 // it is absolutely crucial to check if drawable is already attached, | ||||
|                 //  otherwise we would end up with a loop | ||||
|                 if (drawable.isAttached()) { | ||||
|                     continue; | ||||
|                 } | ||||
| 
 | ||||
|                 drawable.setCallback2(new CallbackAdapter() { | ||||
|                     @Override | ||||
|                     public void invalidateDrawable(@NonNull Drawable who) { | ||||
|                         recreate.run(); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -330,4 +394,21 @@ public class TableRowSpan extends ReplacementSpan { | ||||
|     public void invalidator(@Nullable Invalidator invalidator) { | ||||
|         this.invalidator = invalidator; | ||||
|     } | ||||
| 
 | ||||
|     private static abstract class CallbackAdapter implements Drawable.Callback { | ||||
|         @Override | ||||
|         public void invalidateDrawable(@NonNull Drawable who) { | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void scheduleDrawable(@NonNull Drawable who, @NonNull Runnable what, long when) { | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void unscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what) { | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -21,6 +21,7 @@ public class HtmlEmptyTagReplacement { | ||||
|     } | ||||
| 
 | ||||
|     private static final String IMG_REPLACEMENT = "\uFFFC"; | ||||
|     private static final String IFRAME_REPLACEMENT = "\u00a0"; // non-breakable space | ||||
| 
 | ||||
|     /** | ||||
|      * @return replacement for supplied startTag or null if no replacement should occur (which will | ||||
| @ -44,6 +45,9 @@ public class HtmlEmptyTagReplacement { | ||||
|             } else { | ||||
|                 replacement = alt; | ||||
|             } | ||||
|         } else if ("iframe".equals(name)) { | ||||
|             // @since 4.4.0 make iframe non-empty | ||||
|             replacement = IFRAME_REPLACEMENT; | ||||
|         } else { | ||||
|             replacement = null; | ||||
|         } | ||||
|  | ||||
| @ -53,13 +53,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|     public static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; | ||||
| 
 | ||||
|     private final MarkwonHtmlRendererImpl.Builder builder; | ||||
|     private final MarkwonHtmlParser htmlParser; | ||||
| 
 | ||||
|     private MarkwonHtmlParser htmlParser; | ||||
|     private MarkwonHtmlRenderer htmlRenderer; | ||||
| 
 | ||||
|     // @since 4.4.0 | ||||
|     private HtmlEmptyTagReplacement emptyTagReplacement = new HtmlEmptyTagReplacement(); | ||||
| 
 | ||||
|     @SuppressWarnings("WeakerAccess") | ||||
|     HtmlPlugin() { | ||||
|         this.builder = new MarkwonHtmlRendererImpl.Builder(); | ||||
|         this.htmlParser = MarkwonHtmlParserImpl.create(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -104,6 +107,16 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @param emptyTagReplacement {@link HtmlEmptyTagReplacement} | ||||
|      * @since 4.4.0 | ||||
|      */ | ||||
|     @NonNull | ||||
|     public HtmlPlugin emptyTagReplacement(@NonNull HtmlEmptyTagReplacement emptyTagReplacement) { | ||||
|         this.emptyTagReplacement = emptyTagReplacement; | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
|     public void configureConfiguration(@NonNull MarkwonConfiguration.Builder configurationBuilder) { | ||||
| 
 | ||||
| @ -128,6 +141,7 @@ public class HtmlPlugin extends AbstractMarkwonPlugin { | ||||
|             builder.addDefaultTagHandler(new HeadingHandler()); | ||||
|         } | ||||
| 
 | ||||
|         htmlParser = MarkwonHtmlParserImpl.create(emptyTagReplacement); | ||||
|         htmlRenderer = builder.build(); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -62,7 +62,7 @@ public class ImageHandler extends SimpleTagHandler { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         final String destination = configuration.urlProcessor().process(src); | ||||
|         final String destination = configuration.imageDestinationProcessor().process(src); | ||||
|         final ImageSize imageSize = imageSizeParser.parse(tag.attributes()); | ||||
| 
 | ||||
|         // todo: replacement text is link... as we are not at block level | ||||
|  | ||||
| @ -27,7 +27,8 @@ public class LinkHandler extends SimpleTagHandler { | ||||
| 
 | ||||
|                 CoreProps.LINK_DESTINATION.set( | ||||
|                         renderProps, | ||||
|                         configuration.urlProcessor().process(destination)); | ||||
|                         destination | ||||
|                 ); | ||||
| 
 | ||||
|                 return spanFactory.getSpans(configuration, renderProps); | ||||
|             } | ||||
|  | ||||
| @ -15,7 +15,6 @@ import java.util.Map; | ||||
| 
 | ||||
| import coil.Coil; | ||||
| import coil.ImageLoader; | ||||
| import coil.api.ImageLoaders; | ||||
| import coil.request.LoadRequest; | ||||
| import coil.request.RequestDisposable; | ||||
| import coil.target.Target; | ||||
| @ -48,7 +47,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public LoadRequest load(@NonNull AsyncDrawable drawable) { | ||||
|                 return ImageLoaders.newLoadBuilder(Coil.loader(), context) | ||||
|                 return LoadRequest.builder(context) | ||||
|                         .data(drawable.getDestination()) | ||||
|                         .build(); | ||||
|             } | ||||
| @ -57,7 +56,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin { | ||||
|             public void cancel(@NonNull RequestDisposable disposable) { | ||||
|                 disposable.dispose(); | ||||
|             } | ||||
|         }, Coil.loader()); | ||||
|         }, Coil.imageLoader(context)); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
| @ -67,7 +66,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin { | ||||
|             @NonNull | ||||
|             @Override | ||||
|             public LoadRequest load(@NonNull AsyncDrawable drawable) { | ||||
|                 return ImageLoaders.newLoadBuilder(imageLoader, context) | ||||
|                 return LoadRequest.builder(context) | ||||
|                         .data(drawable.getDestination()) | ||||
|                         .build(); | ||||
|             } | ||||
| @ -129,7 +128,7 @@ public class CoilImagesPlugin extends AbstractMarkwonPlugin { | ||||
|             LoadRequest request = coilStore.load(drawable).newBuilder() | ||||
|                     .target(target) | ||||
|                     .build(); | ||||
|             RequestDisposable disposable = imageLoader.load(request); | ||||
|             RequestDisposable disposable = imageLoader.execute(request); | ||||
|             cache.put(drawable, disposable); | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -3,11 +3,12 @@ package io.noties.markwon.image.file; | ||||
| import android.content.Context; | ||||
| import android.content.res.AssetManager; | ||||
| import android.net.Uri; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import android.text.TextUtils; | ||||
| import android.webkit.MimeTypeMap; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.io.BufferedInputStream; | ||||
| import java.io.File; | ||||
| import java.io.FileInputStream; | ||||
| @ -18,7 +19,6 @@ import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| 
 | ||||
| import io.noties.markwon.urlprocessor.UrlProcessorAndroidAssets; | ||||
| import io.noties.markwon.image.ImageItem; | ||||
| import io.noties.markwon.image.SchemeHandler; | ||||
| 
 | ||||
| @ -30,7 +30,7 @@ public class FileSchemeHandler extends SchemeHandler { | ||||
|     public static final String SCHEME = "file"; | ||||
| 
 | ||||
|     /** | ||||
|      * @see UrlProcessorAndroidAssets | ||||
|      * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static FileSchemeHandler createWithAssets(@NonNull AssetManager assetManager) { | ||||
| @ -39,7 +39,7 @@ public class FileSchemeHandler extends SchemeHandler { | ||||
| 
 | ||||
|     /** | ||||
|      * @see #createWithAssets(AssetManager) | ||||
|      * @see UrlProcessorAndroidAssets | ||||
|      * @see io.noties.markwon.image.destination.ImageDestinationProcessorAssets | ||||
|      * @since 4.0.0 | ||||
|      */ | ||||
|     @NonNull | ||||
|  | ||||
| @ -38,7 +38,8 @@ For example, `@since $nap` seems like a good candidate. For this a live template | ||||
| whenever a new API method/field/functionality-change is introduced (`snc`): | ||||
| 
 | ||||
| ``` | ||||
| @since $nap; | ||||
| // semicolon with a space so this one is not accedentally replaced with release version | ||||
| @since $nap ; | ||||
| ``` | ||||
| 
 | ||||
| This live template would be possible to use in both inline comment and javadoc comment. | ||||
|  | ||||
| @ -24,7 +24,11 @@ | ||||
|         <activity android:name="io.noties.markwon.sample.basicplugins.BasicPluginsActivity" /> | ||||
|         <activity android:name="io.noties.markwon.sample.recycler.RecyclerActivity" /> | ||||
|         <activity android:name="io.noties.markwon.sample.theme.ThemeActivity" /> | ||||
|         <activity android:name=".html.HtmlActivity" /> | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".html.HtmlActivity" | ||||
|             android:exported="true" /> | ||||
| 
 | ||||
|         <activity android:name=".simpleext.SimpleExtActivity" /> | ||||
|         <activity android:name=".customextension2.CustomExtensionActivity2" /> | ||||
|         <activity android:name=".precomputed.PrecomputedActivity" /> | ||||
| @ -32,6 +36,7 @@ | ||||
| 
 | ||||
|         <activity | ||||
|             android:name=".editor.EditorActivity" | ||||
|             android:exported="true" | ||||
|             android:windowSoftInputMode="adjustResize" /> | ||||
| 
 | ||||
|         <activity android:name=".inlineparser.InlineParserActivity" /> | ||||
|  | ||||
| @ -5,6 +5,7 @@ import android.net.Uri; | ||||
| import android.os.Bundle; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.ForegroundColorSpan; | ||||
| import android.view.View; | ||||
| import android.widget.ScrollView; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| @ -20,6 +21,7 @@ import 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.MarkwonSpansFactory; | ||||
| @ -153,7 +155,7 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions { | ||||
|      * <ul> | ||||
|      * <li>SyntaxHighlight</li> | ||||
|      * <li>LinkSpan.Resolver</li> | ||||
|      * <li>UrlProcessor</li> | ||||
|      * <li>ImageDestinationProcessor</li> | ||||
|      * <li>ImageSizeResolver</li> | ||||
|      * </ul> | ||||
|      * <p> | ||||
| @ -173,12 +175,18 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions { | ||||
|                     public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { | ||||
|                         // for example if specified destination has no scheme info, we will | ||||
|                         // _assume_ that it's network request and append HTTPS scheme | ||||
|                         builder.urlProcessor(destination -> { | ||||
|                             final Uri uri = Uri.parse(destination); | ||||
|                             if (TextUtils.isEmpty(uri.getScheme())) { | ||||
|                                 return "https://" + destination; | ||||
|                         builder.linkResolver(new LinkResolverDef() { | ||||
|                             @Override | ||||
|                             public void resolve(@NonNull View view, @NonNull String link) { | ||||
|                                 final String destination; | ||||
|                                 final Uri uri = Uri.parse(link); | ||||
|                                 if (TextUtils.isEmpty(uri.getScheme())) { | ||||
|                                     destination = "https://" + link; | ||||
|                                 } else { | ||||
|                                     destination = link; | ||||
|                                 } | ||||
|                                 super.resolve(view, destination); | ||||
|                             } | ||||
|                             return destination; | ||||
|                         }); | ||||
|                     } | ||||
|                 }) | ||||
| @ -434,4 +442,25 @@ public class BasicPluginsActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
| //    private void code() { | ||||
| //        final String md = "" + | ||||
| //                "hello `there`!\n\n" + | ||||
| //                "so this, `is super duper long very very very long line that should be going further and further and further down` yep.\n\n" + | ||||
| //                "`okay`"; | ||||
| //        final Markwon markwon = Markwon.builder(this) | ||||
| //                .usePlugin(new AbstractMarkwonPlugin() { | ||||
| //                    @Override | ||||
| //                    public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { | ||||
| //                        builder.setFactory(Code.class, new SpanFactory() { | ||||
| //                            @Override | ||||
| //                            public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps props) { | ||||
| //                                return new CodeTextView.CodeSpan(); | ||||
| //                            } | ||||
| //                        }); | ||||
| //                    } | ||||
| //                }) | ||||
| //                .build(); | ||||
| //        markwon.setMarkdown(textView, md); | ||||
| //    } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,192 @@ | ||||
| package io.noties.markwon.sample.basicplugins; | ||||
| 
 | ||||
| import android.annotation.SuppressLint; | ||||
| import android.content.Context; | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.RectF; | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.util.AttributeSet; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| 
 | ||||
| @SuppressLint("AppCompatCustomView") | ||||
| public class CodeTextView extends TextView { | ||||
| 
 | ||||
|     static class CodeSpan { | ||||
|     } | ||||
| 
 | ||||
|     private int paddingHorizontal; | ||||
|     private int paddingVertical; | ||||
| 
 | ||||
|     private float cornerRadius; | ||||
|     private float strokeWidth; | ||||
|     private int strokeColor; | ||||
|     private int backgroundColor; | ||||
| 
 | ||||
|     public CodeTextView(Context context) { | ||||
|         super(context); | ||||
|         init(context, null); | ||||
|     } | ||||
| 
 | ||||
|     public CodeTextView(Context context, AttributeSet attrs) { | ||||
|         super(context, attrs); | ||||
|         init(context, attrs); | ||||
|     } | ||||
| 
 | ||||
|     private void init(Context context, @Nullable AttributeSet attrs) { | ||||
|         paint.setColor(0xFFff0000); | ||||
|         paint.setStyle(Paint.Style.FILL); | ||||
|     } | ||||
| 
 | ||||
|     private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); | ||||
| 
 | ||||
|     @Override | ||||
|     protected void onDraw(Canvas canvas) { | ||||
|         final Layout layout = getLayout(); | ||||
|         if (layout != null) { | ||||
|             draw(this, canvas, layout); | ||||
|         } | ||||
|         super.onDraw(canvas); | ||||
|     } | ||||
| 
 | ||||
|     private void draw( | ||||
|             @NonNull View view, | ||||
|             @NonNull Canvas canvas, | ||||
|             @NonNull Layout layout | ||||
|     ) { | ||||
| 
 | ||||
|         final CharSequence cs = layout.getText(); | ||||
|         if (!(cs instanceof Spanned)) { | ||||
|             return; | ||||
|         } | ||||
|         final Spanned spanned = (Spanned) cs; | ||||
| 
 | ||||
|         final int save = canvas.save(); | ||||
|         try { | ||||
|             canvas.translate(view.getPaddingLeft(), view.getPaddingTop()); | ||||
| 
 | ||||
|             // TODO: block? | ||||
|             // TODO: we must remove _original_ spans | ||||
|             // TODO: cache (attach a listener?) | ||||
|             // TODO: editor? | ||||
| 
 | ||||
|             final CodeSpan[] spans = spanned.getSpans(0, spanned.length(), CodeSpan.class); | ||||
|             if (spans != null && spans.length > 0) { | ||||
|                 for (CodeSpan span : spans) { | ||||
| 
 | ||||
|                     final int startOffset = spanned.getSpanStart(span); | ||||
|                     final int endOffset = spanned.getSpanEnd(span); | ||||
| 
 | ||||
|                     final int startLine = layout.getLineForOffset(startOffset); | ||||
|                     final int endLine = layout.getLineForOffset(endOffset); | ||||
| 
 | ||||
|                     // do we need to round them? | ||||
|                     final float left = layout.getPrimaryHorizontal(startOffset) | ||||
|                             + (-1 * layout.getParagraphDirection(startLine) * paddingHorizontal); | ||||
| 
 | ||||
|                     final float right = layout.getPrimaryHorizontal(endOffset) | ||||
|                             + (layout.getParagraphDirection(endLine) * paddingHorizontal); | ||||
| 
 | ||||
|                     final float top = getLineTop(layout, startLine, paddingVertical); | ||||
|                     final float bottom = getLineBottom(layout, endLine, paddingVertical); | ||||
| 
 | ||||
|                     Debug.i(new RectF(left, top, right, bottom).toShortString()); | ||||
| 
 | ||||
|                     if (startLine == endLine) { | ||||
|                         canvas.drawRect(left, top, right, bottom, paint); | ||||
|                     } else { | ||||
|                         // draw first line (start until the lineEnd) | ||||
|                         // draw everything in-between (startLine - endLine) | ||||
|                         // draw last line (lineStart until the end | ||||
| 
 | ||||
|                         canvas.drawRect( | ||||
|                                 left, | ||||
|                                 top, | ||||
|                                 layout.getLineRight(startLine), | ||||
|                                 getLineBottom(layout, startLine, paddingVertical), | ||||
|                                 paint | ||||
|                         ); | ||||
| 
 | ||||
|                         for (int line = startLine + 1; line < endLine; line++) { | ||||
|                             canvas.drawRect( | ||||
|                                     layout.getLineLeft(line), | ||||
|                                     getLineTop(layout, line, paddingVertical), | ||||
|                                     layout.getLineRight(line), | ||||
|                                     getLineBottom(layout, line, paddingVertical), | ||||
|                                     paint | ||||
|                             ); | ||||
|                         } | ||||
| 
 | ||||
|                         canvas.drawRect( | ||||
|                                 layout.getLineLeft(endLine), | ||||
|                                 getLineTop(layout, endLine, paddingVertical), | ||||
|                                 right, | ||||
|                                 getLineBottom(layout, endLine, paddingVertical), | ||||
|                                 paint | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } finally { | ||||
|             canvas.restoreToCount(save); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineTop(@NonNull Layout layout, int line, float padding) { | ||||
|         float value = layout.getLineTop(line) - padding; | ||||
|         if (line == 0) { | ||||
|             value -= layout.getTopPadding(); | ||||
|         } | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineBottom(@NonNull Layout layout, int line, float padding) { | ||||
|         float value = getLineBottomWithoutSpacing(layout, line) - padding; | ||||
|         if (line == (layout.getLineCount() - 1)) { | ||||
|             value -= layout.getBottomPadding(); | ||||
|         } | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineBottomWithoutSpacing(@NonNull Layout layout, int line) { | ||||
|         final float value = layout.getLineBottom(line); | ||||
| 
 | ||||
|         final boolean isLastLineSpacingNotAdded = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; | ||||
|         final boolean isLastLine = line == (layout.getLineCount() - 1); | ||||
| 
 | ||||
|         final float lineBottomWithoutSpacing; | ||||
| 
 | ||||
|         final float lineSpacingExtra = layout.getSpacingAdd(); | ||||
|         final float lineSpacingMultiplier = layout.getSpacingMultiplier(); | ||||
| 
 | ||||
|         final boolean hasLineSpacing = Float.compare(lineSpacingExtra, .0F) != 0 | ||||
|                 || Float.compare(lineSpacingMultiplier, 1F) != 0; | ||||
| 
 | ||||
|         if (!hasLineSpacing || isLastLine && isLastLineSpacingNotAdded) { | ||||
|             lineBottomWithoutSpacing = value; | ||||
|         } else { | ||||
|             final float extra; | ||||
|             if (Float.compare(lineSpacingMultiplier, 1F) != 0) { | ||||
|                 final float lineHeight = getLineHeight(layout, line); | ||||
|                 extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier; | ||||
|             } else { | ||||
|                 extra = lineSpacingExtra; | ||||
|             } | ||||
|             lineBottomWithoutSpacing = value - extra; | ||||
|         } | ||||
| 
 | ||||
|         return lineBottomWithoutSpacing; | ||||
|     } | ||||
| 
 | ||||
|     private static float getLineHeight(@NonNull Layout layout, int line) { | ||||
|         return layout.getLineTop(line + 1) - layout.getLineTop(line); | ||||
|     } | ||||
| } | ||||
| @ -8,8 +8,14 @@ import android.widget.Toast; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Block; | ||||
| import org.commonmark.node.BlockQuote; | ||||
| import org.commonmark.node.Node; | ||||
| import org.commonmark.parser.Parser; | ||||
| 
 | ||||
| import java.util.Set; | ||||
| 
 | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.core.CorePlugin; | ||||
| import io.noties.markwon.sample.ActivityWithMenuOptions; | ||||
| @ -26,7 +32,8 @@ public class CoreActivity extends ActivityWithMenuOptions { | ||||
|         return MenuOptions.create() | ||||
|                 .add("simple", this::simple) | ||||
|                 .add("toast", this::toast) | ||||
|                 .add("alreadyParsed", this::alreadyParsed); | ||||
|                 .add("alreadyParsed", this::alreadyParsed) | ||||
|                 .add("enabledBlockTypes", this::enabledBlockTypes); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -132,4 +139,28 @@ public class CoreActivity extends ActivityWithMenuOptions { | ||||
|         // apply parsed markdown | ||||
|         markwon.setParsedMarkdown(textView, spanned); | ||||
|     } | ||||
| 
 | ||||
|     private void enabledBlockTypes() { | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 "# Head\n\n" + | ||||
|                 "> and disabled quote\n\n" + | ||||
|                 "```\n" + | ||||
|                 "yep\n" + | ||||
|                 "```"; | ||||
| 
 | ||||
|         final Set<Class<? extends Block>> blocks = CorePlugin.enabledBlockTypes(); | ||||
|         blocks.remove(BlockQuote.class); | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder.enabledBlockTypes(blocks); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -6,6 +6,7 @@ import android.text.SpannableStringBuilder; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextPaint; | ||||
| import android.text.TextUtils; | ||||
| import android.text.TextWatcher; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.style.ForegroundColorSpan; | ||||
| import android.text.style.MetricAffectingSpan; | ||||
| @ -25,8 +26,11 @@ import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.Executors; | ||||
| 
 | ||||
| import io.noties.debug.AndroidLogDebugOutput; | ||||
| import io.noties.debug.Debug; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.SoftBreakAddsNewLinePlugin; | ||||
| import io.noties.markwon.core.spans.EmphasisSpan; | ||||
| import io.noties.markwon.core.spans.StrongEmphasisSpan; | ||||
| import io.noties.markwon.editor.AbstractEditHandler; | ||||
| @ -65,7 +69,8 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 .add("multipleEditSpansPlugin", this::multiple_edit_spans_plugin) | ||||
|                 .add("pluginRequire", this::plugin_require) | ||||
|                 .add("pluginNoDefaults", this::plugin_no_defaults) | ||||
|                 .add("heading", this::heading); | ||||
|                 .add("heading", this::heading) | ||||
|                 .add("newLine", this::newLine); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -98,7 +103,10 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|         super.onCreate(savedInstanceState); | ||||
|         createView(); | ||||
| 
 | ||||
|         Debug.init(new AndroidLogDebugOutput(true)); | ||||
| 
 | ||||
|         multiple_edit_spans(); | ||||
| //        newLine(); | ||||
|     } | ||||
| 
 | ||||
|     private void simple_process() { | ||||
| @ -230,6 +238,7 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                         builder.inlineParserFactory(inlineParserFactory); | ||||
|                     } | ||||
|                 }) | ||||
|                 .usePlugin(SoftBreakAddsNewLinePlugin.create()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         final LinkEditHandler.OnClick onClick = (widget, link) -> markwon.configuration().linkResolver().resolve(widget, link); | ||||
| @ -280,6 +289,13 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 editor, Executors.newSingleThreadExecutor(), editText)); | ||||
|     } | ||||
| 
 | ||||
|     private void newLine() { | ||||
|         final Markwon markwon = Markwon.create(this); | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
|         final TextWatcher textWatcher = MarkdownNewLine.wrap(MarkwonEditorTextWatcher.withProcess(editor)); | ||||
|         editText.addTextChangedListener(textWatcher); | ||||
|     } | ||||
| 
 | ||||
|     private void plugin_require() { | ||||
|         // usage of plugin from other plugins | ||||
| 
 | ||||
| @ -295,6 +311,8 @@ public class EditorActivity extends ActivityWithMenuOptions { | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         editText.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
| 
 | ||||
|         final MarkwonEditor editor = MarkwonEditor.create(markwon); | ||||
| 
 | ||||
|         editText.addTextChangedListener(MarkwonEditorTextWatcher.withPreRender( | ||||
|  | ||||
| @ -0,0 +1,129 @@ | ||||
| package io.noties.markwon.sample.editor; | ||||
| 
 | ||||
| import android.text.Editable; | ||||
| import android.text.TextUtils; | ||||
| import android.text.TextWatcher; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| 
 | ||||
| abstract class MarkdownNewLine { | ||||
| 
 | ||||
|     @NonNull | ||||
|     static TextWatcher wrap(@NonNull TextWatcher textWatcher) { | ||||
|         return new NewLineTextWatcher(textWatcher); | ||||
|     } | ||||
| 
 | ||||
|     private MarkdownNewLine() { | ||||
|     } | ||||
| 
 | ||||
|     private static class NewLineTextWatcher implements TextWatcher { | ||||
| 
 | ||||
|         // NB! matches only bullet lists | ||||
|         private final Pattern RE = Pattern.compile("^( {0,3}[\\-+* ]+)(.+)*$"); | ||||
| 
 | ||||
|         private final TextWatcher wrapped; | ||||
| 
 | ||||
|         private boolean selfChange; | ||||
| 
 | ||||
|         // this content is pending to be inserted at the beginning | ||||
|         private String pendingNewLineContent; | ||||
|         private int pendingNewLineIndex; | ||||
| 
 | ||||
|         // mark current edited line for removal (range start/end) | ||||
|         private int clearLineStart; | ||||
|         private int clearLineEnd; | ||||
| 
 | ||||
|         NewLineTextWatcher(@NonNull TextWatcher wrapped) { | ||||
|             this.wrapped = wrapped; | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void beforeTextChanged(CharSequence s, int start, int count, int after) { | ||||
|             // no op | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void onTextChanged(CharSequence s, int start, int before, int count) { | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // just one new character added | ||||
|             if (before == 0 | ||||
|                     && count == 1 | ||||
|                     && '\n' == s.charAt(start)) { | ||||
|                 int end = -1; | ||||
|                 for (int i = start - 1; i >= 0; i--) { | ||||
|                     if ('\n' == s.charAt(i)) { | ||||
|                         end = i + 1; | ||||
|                         break; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 // start at the very beginning | ||||
|                 if (end < 0) { | ||||
|                     end = 0; | ||||
|                 } | ||||
| 
 | ||||
|                 final String pendingNewLineContent; | ||||
| 
 | ||||
|                 final int clearLineStart; | ||||
|                 final int clearLineEnd; | ||||
| 
 | ||||
|                 final Matcher matcher = RE.matcher(s.subSequence(end, start)); | ||||
|                 if (matcher.matches()) { | ||||
|                     // if second group is empty -> remove new line | ||||
|                     final String content = matcher.group(2); | ||||
|                     Debug.e("new line, content: '%s'", content); | ||||
|                     if (TextUtils.isEmpty(content)) { | ||||
|                         // another empty new line, remove this start | ||||
|                         clearLineStart = end; | ||||
|                         clearLineEnd = start; | ||||
|                         pendingNewLineContent = null; | ||||
|                     } else { | ||||
|                         pendingNewLineContent = matcher.group(1); | ||||
|                         clearLineStart = clearLineEnd = 0; | ||||
|                     } | ||||
|                 } else { | ||||
|                     pendingNewLineContent = null; | ||||
|                     clearLineStart = clearLineEnd = 0; | ||||
|                 } | ||||
|                 this.pendingNewLineContent = pendingNewLineContent; | ||||
|                 this.pendingNewLineIndex = start + 1; | ||||
|                 this.clearLineStart = clearLineStart; | ||||
|                 this.clearLineEnd = clearLineEnd; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         @Override | ||||
|         public void afterTextChanged(Editable s) { | ||||
|             if (selfChange) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (pendingNewLineContent != null || clearLineStart < clearLineEnd) { | ||||
|                 selfChange = true; | ||||
|                 try { | ||||
|                     if (pendingNewLineContent != null) { | ||||
|                         s.insert(pendingNewLineIndex, pendingNewLineContent); | ||||
|                         pendingNewLineContent = null; | ||||
|                     } else { | ||||
|                         s.replace(clearLineStart, clearLineEnd, ""); | ||||
|                         clearLineStart = clearLineEnd = 0; | ||||
|                     } | ||||
|                 } finally { | ||||
|                     selfChange = false; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // NB, we assume MarkdownEditor text watcher that only listens for this event, | ||||
|             // other text-watchers must be interested in other events also | ||||
|             wrapped.afterTextChanged(s); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,242 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.graphics.Canvas; | ||||
| import android.graphics.Paint; | ||||
| import android.graphics.Path; | ||||
| import android.os.Build; | ||||
| import android.text.Layout; | ||||
| import android.text.Spanned; | ||||
| import android.text.TextPaint; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.LineBackgroundSpan; | ||||
| import android.text.style.MetricAffectingSpan; | ||||
| import android.util.Log; | ||||
| import android.widget.TextView; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Px; | ||||
| import androidx.annotation.RequiresApi; | ||||
| 
 | ||||
| import io.noties.markwon.core.spans.TextLayoutSpan; | ||||
| import io.noties.markwon.core.spans.TextViewSpan; | ||||
| 
 | ||||
| import static java.lang.Math.max; | ||||
| import static java.lang.Math.min; | ||||
| 
 | ||||
| /** | ||||
|  * Credit goes to [Romain Guy](https://github.com/romainguy/elegant-underline) | ||||
|  * <p> | ||||
|  * Failed attempt to create elegant underline as a span | ||||
|  * <ul> | ||||
|  * <li>in a `TextView` span is rendered, but `draw` method is invoked constantly which put pressure on CPU and memory | ||||
|  * <li>in an `EditText` only the first line draws this underline span (seems to be a weird | ||||
|  * issue between LineBackgroundSpan and EditText). Also, in `EditText` `draw` method is invoked | ||||
|  * constantly (for each drawing of the blinking cursor) | ||||
|  * <li>cannot reliably receive proper text, for example if underline is applied to a text range which has | ||||
|  * different typefaces applied to different words (underline cannot know that, which applied to which) | ||||
|  * </ul> | ||||
|  */ | ||||
| // will apply other spans that 100% contain this one, so for example if | ||||
| // an underline that inside some other spans (different typeface), they won't be applied and thus | ||||
| // underline would be incorrect | ||||
| // do not use in editor, due to some obscure thing, LineBackgroundSpan would be applied to the first line only | ||||
| // also, in editor this span would be redrawn with each blink of the cursor | ||||
| @RequiresApi(Build.VERSION_CODES.KITKAT) | ||||
| class ElegantUnderlineSpan implements LineBackgroundSpan { | ||||
| 
 | ||||
|     private static final float DEFAULT_UNDERLINE_HEIGHT_DIP = 0.8F; | ||||
|     private static final float DEFAULT_UNDERLINE_CLEAR_GAP_DIP = 5.5F; | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create() { | ||||
|         return new ElegantUnderlineSpan(0, 0); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create(@Px int underlineHeight) { | ||||
|         return new ElegantUnderlineSpan(underlineHeight, 0); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     public static ElegantUnderlineSpan create(@Px int underlineHeight, @Px int underlineClearGap) { | ||||
|         return new ElegantUnderlineSpan(underlineHeight, underlineClearGap); | ||||
|     } | ||||
| 
 | ||||
|     // TODO: underline color? | ||||
|     private final int underlineHeight; | ||||
|     private final int underlineClearGap; | ||||
| 
 | ||||
|     private final Path underline = new Path(); | ||||
|     private final Path outline = new Path(); | ||||
|     private final Paint stroke = new Paint(); | ||||
|     private final Path strokedOutline = new Path(); | ||||
| 
 | ||||
|     private final CharCache charCache = new CharCache(); | ||||
| 
 | ||||
|     private final TextPaint tempTextPaint = new TextPaint(); | ||||
| 
 | ||||
|     protected ElegantUnderlineSpan(@Px int underlineHeight, @Px int underlineClearGap) { | ||||
|         this.underlineHeight = underlineHeight; | ||||
|         this.underlineClearGap = underlineClearGap; | ||||
|         stroke.setStyle(Paint.Style.FILL_AND_STROKE); | ||||
|         stroke.setStrokeCap(Paint.Cap.BUTT); | ||||
|     } | ||||
| 
 | ||||
|     // is it possible that LineBackgroundSpan is not receiving proper spans? like typeface? | ||||
|     //  it complicates things (like the need to have own copy of paint) | ||||
| 
 | ||||
|     // is it possible that LineBackgroundSpan is called constantly even in a TextView? | ||||
| 
 | ||||
|     @Override | ||||
|     public void drawBackground( | ||||
|             Canvas c, | ||||
|             Paint p, | ||||
|             int left, | ||||
|             int right, | ||||
|             int top, | ||||
|             int baseline, | ||||
|             int bottom, | ||||
|             CharSequence text, | ||||
|             int start, | ||||
|             int end, | ||||
|             int lnum | ||||
|     ) { | ||||
| 
 | ||||
| //        Debug.trace(); | ||||
| 
 | ||||
|         final Spanned spanned = (Spanned) text; | ||||
|         final TextView textView = TextViewSpan.textViewOf(spanned); | ||||
| 
 | ||||
|         if (textView == null) { | ||||
|             // TextView is required | ||||
|             Log.e("EU", "no text view"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         final Layout layout; | ||||
|         { | ||||
|             // check if there is dedicated layout, if not, use from textView | ||||
|             //  (think tableRowSpan that uses own Layout) | ||||
|             final Layout layoutFromSpan = TextLayoutSpan.layoutOf(spanned); | ||||
|             if (layoutFromSpan != null) { | ||||
|                 layout = layoutFromSpan; | ||||
|             } else { | ||||
|                 layout = textView.getLayout(); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (layout == null) { | ||||
|             // we could call `p.setUnderlineText(true)` here a fallback, | ||||
|             //  but this would make __all__ text in a TextView underlined, which is not | ||||
|             //  what we want | ||||
|             Log.e("EU", "no layout"); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         tempTextPaint.set((TextPaint) p); | ||||
| 
 | ||||
|         // we must use _selfStart_ because underline can start **not** at the beginning of a line. | ||||
|         // as we are using LineBackground `start` would indicate the start position of the line | ||||
|         //  and not start of the span (self). The same goes for selfEnd (ended before line) | ||||
|         final int selfStart = spanned.getSpanStart(this); | ||||
|         final int selfEnd = spanned.getSpanEnd(this); | ||||
| 
 | ||||
|         final int s = max(selfStart, start); | ||||
| 
 | ||||
|         // all lines should use (end - 1) to receive proper line end coordinate X, | ||||
|         //  unless it is last line in _layout_ | ||||
|         final boolean isLastLine = lnum == (layout.getLineCount() - 1); | ||||
|         final int e = min(selfEnd, end - (isLastLine ? 0 : 1)); | ||||
| 
 | ||||
|         if (true) { | ||||
|             Log.e("EU", String.format("lnum: %s, hash: %s, text: '%s'", | ||||
|                     lnum, text.subSequence(s, e).hashCode(), text.subSequence(s, e))); | ||||
|         } | ||||
| 
 | ||||
|         final int leading; | ||||
|         final int trailing; | ||||
|         { | ||||
|             final int l = (int) (layout.getPrimaryHorizontal(s) + .5F); | ||||
|             final int r = (int) (layout.getPrimaryHorizontal(e) + .5F); | ||||
|             leading = min(l, r); | ||||
|             trailing = max(l, r); | ||||
|         } | ||||
| 
 | ||||
|         underline.rewind(); | ||||
| 
 | ||||
|         // middle between baseline and descent | ||||
|         final int diff = (int) (p.descent() / 2F + .5F); | ||||
| 
 | ||||
|         underline.addRect( | ||||
|                 leading, baseline + diff, | ||||
|                 trailing, baseline + diff + underlineHeight(textView), | ||||
|                 Path.Direction.CW | ||||
|         ); | ||||
| 
 | ||||
|         outline.rewind(); | ||||
| 
 | ||||
|         final int charsLength = e - s; | ||||
|         final char[] chars = charCache.chars(charsLength); | ||||
|         TextUtils.getChars(spanned, s, e, chars, 0); | ||||
| 
 | ||||
|         if (true) { | ||||
|             final MetricAffectingSpan[] metricAffectingSpans = spanned.getSpans(s, e, MetricAffectingSpan.class); | ||||
| //            Log.e("EU", Arrays.toString(metricAffectingSpans)); | ||||
|             for (MetricAffectingSpan span : metricAffectingSpans) { | ||||
|                 span.updateMeasureState(tempTextPaint); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // todo: styleSpan | ||||
|         // todo all other spans (maybe UpdateMeasureSpans?) | ||||
|         tempTextPaint.getTextPath( | ||||
|                 chars, | ||||
|                 0, charsLength, | ||||
|                 leading, baseline, | ||||
|                 outline | ||||
|         ); | ||||
| 
 | ||||
|         outline.op(underline, Path.Op.INTERSECT); | ||||
| 
 | ||||
|         strokedOutline.rewind(); | ||||
|         stroke.setStrokeWidth(underlineClearGap(textView)); | ||||
|         stroke.getFillPath(outline, strokedOutline); | ||||
| 
 | ||||
|         underline.op(strokedOutline, Path.Op.DIFFERENCE); | ||||
| 
 | ||||
|         c.drawPath(underline, p); | ||||
|     } | ||||
| 
 | ||||
|     private int underlineHeight(@NonNull TextView textView) { | ||||
|         if (underlineHeight > 0) { | ||||
|             return underlineHeight; | ||||
|         } | ||||
|         return (int) (DEFAULT_UNDERLINE_HEIGHT_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); | ||||
|     } | ||||
| 
 | ||||
|     private int underlineClearGap(@NonNull TextView textView) { | ||||
|         if (underlineClearGap > 0) { | ||||
|             return underlineClearGap; | ||||
|         } | ||||
|         return (int) (DEFAULT_UNDERLINE_CLEAR_GAP_DIP * textView.getResources().getDisplayMetrics().density + 0.5F); | ||||
|     } | ||||
| 
 | ||||
|     // primitive cache that grows internal array (never shrinks, nor clear buffer) | ||||
|     // TODO: but... each span has own instance, so not much of the memory saving | ||||
|     private static class CharCache { | ||||
| 
 | ||||
|         @NonNull | ||||
|         char[] chars(int ofLength) { | ||||
|             final char[] out; | ||||
|             if (chars == null || chars.length < ofLength) { | ||||
|                 out = chars = new char[ofLength]; | ||||
|             } else { | ||||
|                 out = chars; | ||||
|             } | ||||
|             return out; | ||||
|         } | ||||
| 
 | ||||
|         private char[] chars; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @ -1,18 +1,18 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| import android.os.Bundle; | ||||
| import android.text.Layout; | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.AbsoluteSizeSpan; | ||||
| import android.text.style.AlignmentSpan; | ||||
| import android.widget.TextView; | ||||
| import android.widget.Toast; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.annotation.Px; | ||||
| 
 | ||||
| import org.commonmark.node.Paragraph; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.Random; | ||||
| @ -23,6 +23,7 @@ import io.noties.markwon.MarkwonConfiguration; | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlEmptyTagReplacement; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| @ -42,7 +43,10 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
|                 .add("align", this::align) | ||||
|                 .add("randomCharSize", this::randomCharSize) | ||||
|                 .add("enhance", this::enhance) | ||||
|                 .add("image", this::image); | ||||
|                 .add("image", this::image) | ||||
| //                .add("elegantUnderline", this::elegantUnderline) | ||||
|                 .add("iframe", this::iframe) | ||||
|                 .add("emptyTagReplacement", this::emptyTagReplacement); | ||||
|     } | ||||
| 
 | ||||
|     private TextView textView; | ||||
| @ -56,8 +60,8 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
|         // let's define some custom tag-handlers | ||||
| 
 | ||||
|         textView = findViewById(R.id.text_view); | ||||
| 
 | ||||
|         align(); | ||||
|          | ||||
|         emptyTagReplacement(); | ||||
|     } | ||||
| 
 | ||||
|     // we can use `SimpleTagHandler` for _simple_ cases (when the whole tag content | ||||
| @ -268,4 +272,86 @@ public class HtmlActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void elegantUnderline() { | ||||
| 
 | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { | ||||
|             Toast.makeText( | ||||
|                     this, | ||||
|                     "Elegant underline is supported on KitKat and up", | ||||
|                     Toast.LENGTH_LONG | ||||
|             ).show(); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         final String underline = "Well well wel, and now <u>Gogogo, quite **perfect** yeah</u> and nice and elegant"; | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 underline + "\n\n" + | ||||
|                 "<b>" + underline + "</b>\n\n" + | ||||
|                 "<font name=serif>" + underline + "</font>\n\n" + | ||||
|                 "<font name=sans-serif>" + underline + underline + underline + "</font>\n\n" + | ||||
|                 "<font name=monospace>" + underline + "</font>\n\n" + | ||||
|                 ""; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(HtmlPlugin.create(plugin -> plugin | ||||
|                         .addHandler(new HtmlFontTagHandler()) | ||||
|                         .addHandler(new HtmlElegantUnderlineTagHandler()))) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void iframe() { | ||||
|         final String md = "" + | ||||
|                 "# Hello iframe\n\n" + | ||||
|                 "<p class=\"p1\"><img title=\"JUMP FORCE\" src=\"https://img1.ak.crunchyroll.com/i/spire1/f0c009039dd9f8dff5907fff148adfca1587067000_full.jpg\" alt=\"JUMP FORCE\" width=\"640\" height=\"362\" /></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Switch owners will soon get to take part in the ultimate <em>Shonen Jump </em>rumble. Bandai Namco announced plans to bring <strong><em>Jump Force </em></strong>to <strong>Switch</strong> as <strong><em>Jump Force Deluxe Edition</em></strong>, with a release set for sometime this year. This version will include all of the original playable characters and those from Character Pass 1, and <strong>Character Pass 2 is also in the works </strong>for all versions, starting with <strong>Shoto Todoroki from </strong><span style=\"color: #ff9900;\"><a href=\"/my-hero-academia?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><strong><em>My Hero Academia</em></strong></span></a></span>.</p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Other than Todoroki, Bandai Namco hinted that the four other Character Pass 2 characters will hail from <span style=\"color: #ff9900;\"><a href=\"/hunter-x-hunter?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Hunter x Hunter</em></span></a></span>, <em>Yu Yu Hakusho</em>, <span style=\"color: #ff9900;\"><a href=\"/bleach?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>Bleach</em></span></a></span>, and <span style=\"color: #ff9900;\"><a href=\"/jojos-bizarre-adventure?utm_source=editorial_cr&utm_medium=news&utm_campaign=article_driven&referrer=editorial_cr_news_article_driven\"><span style=\"color: #ff9900;\"><em>JoJo's Bizarre Adventure</em></span></a></span>. Character Pass 2 will be priced at $17.99, and Todoroki launches this spring.<span class=\"Apple-converted-space\"> </span></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/At1qTj-LWCc\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">Character Pass 2 promo:</p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><iframe style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://www.youtube.com/embed/CukwN6kV4R4\" frameborder=\"0\" width=\"640\" height=\"360\"></iframe></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\"><a href=\"https://got.cr/PremiumTrial-NewsBanner4\"><img style=\"display: block; margin-left: auto; margin-right: auto;\" src=\"https://img1.ak.crunchyroll.com/i/spire4/78f5441d927cf160a93e037b567c2b1f1587067041_full.png\" alt=\"\" width=\"640\" height=\"43\" /></a></p>\n" + | ||||
|                 "<p class=\"p2\"> </p>\n" + | ||||
|                 "<p class=\"p1\">-------</p>\n" + | ||||
|                 "<p class=\"p1\"><em>Joseph Luster is the Games and Web editor at </em><a href=\"http://www.otakuusamagazine.com/ME2/Default.asp\"><em>Otaku USA Magazine</em></a><em>. You can read his webcomic, </em><a href=\"http://subhumanzoids.com/comics/big-dumb-fighting-idiots/\">BIG DUMB FIGHTING IDIOTS</a><em> at </em><a href=\"http://subhumanzoids.com/\"><em>subhumanzoids</em></a><em>. Follow him on Twitter </em><a href=\"https://twitter.com/Moldilox\"><em>@Moldilox</em></a><em>.</em><span class=\"Apple-converted-space\"> </span></p>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(ImagesPlugin.create()) | ||||
|                 .usePlugin(HtmlPlugin.create()) | ||||
|                 .usePlugin(new IFrameHtmlPlugin()) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void emptyTagReplacement() { | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 "<empty></empty> the `<empty></empty>` is replaced?"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(HtmlPlugin.create(plugin -> { | ||||
|                     plugin.emptyTagReplacement(new HtmlEmptyTagReplacement() { | ||||
|                         @Nullable | ||||
|                         @Override | ||||
|                         public String replace(@NonNull HtmlTag tag) { | ||||
|                             if ("empty".equals(tag.name())) { | ||||
|                                 return "REPLACED_EMPTY_WITH_IT"; | ||||
|                             } | ||||
|                             return super.replace(tag); | ||||
|                         } | ||||
|                     }); | ||||
|                 })) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -0,0 +1,38 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.os.Build; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.RequiresApi; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| import io.noties.markwon.html.TagHandler; | ||||
| 
 | ||||
| @RequiresApi(Build.VERSION_CODES.KITKAT) | ||||
| public class HtmlElegantUnderlineTagHandler extends TagHandler { | ||||
| 
 | ||||
|     @Override | ||||
|     public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { | ||||
|         if (tag.isBlock()) { | ||||
|             visitChildren(visitor, renderer, tag.getAsBlock()); | ||||
|         } | ||||
|         SpannableBuilder.setSpans( | ||||
|                 visitor.builder(), | ||||
|                 ElegantUnderlineSpan.create(), | ||||
|                 tag.start(), | ||||
|                 tag.end() | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<String> supportedTags() { | ||||
|         return Collections.singleton("u"); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,42 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import android.text.TextUtils; | ||||
| import android.text.style.TypefaceSpan; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.SpannableBuilder; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.MarkwonHtmlRenderer; | ||||
| import io.noties.markwon.html.TagHandler; | ||||
| 
 | ||||
| public class HtmlFontTagHandler extends TagHandler { | ||||
| 
 | ||||
|     @Override | ||||
|     public void handle(@NonNull MarkwonVisitor visitor, @NonNull MarkwonHtmlRenderer renderer, @NonNull HtmlTag tag) { | ||||
| 
 | ||||
|         if (tag.isBlock()) { | ||||
|             visitChildren(visitor, renderer, tag.getAsBlock()); | ||||
|         } | ||||
| 
 | ||||
|         final String font = tag.attributes().get("name"); | ||||
|         if (!TextUtils.isEmpty(font)) { | ||||
|             SpannableBuilder.setSpans( | ||||
|                     visitor.builder(), | ||||
|                     new TypefaceSpan(font), | ||||
|                     tag.start(), | ||||
|                     tag.end() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Collection<String> supportedTags() { | ||||
|         return Collections.singleton("font"); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,48 @@ | ||||
| package io.noties.markwon.sample.html; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.node.Image; | ||||
| 
 | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.MarkwonConfiguration; | ||||
| import io.noties.markwon.RenderProps; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.html.HtmlTag; | ||||
| import io.noties.markwon.html.tag.SimpleTagHandler; | ||||
| import io.noties.markwon.image.ImageProps; | ||||
| import io.noties.markwon.image.ImageSize; | ||||
| 
 | ||||
| public class IFrameHtmlPlugin extends AbstractMarkwonPlugin { | ||||
|     @Override | ||||
|     public void configure(@NonNull Registry registry) { | ||||
|         registry.require(HtmlPlugin.class, htmlPlugin -> { | ||||
|             // TODO: empty tag replacement | ||||
|             htmlPlugin.addHandler(new EmbedTagHandler()); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static class EmbedTagHandler extends SimpleTagHandler { | ||||
| 
 | ||||
|         @Nullable | ||||
|         @Override | ||||
|         public Object getSpans(@NonNull MarkwonConfiguration configuration, @NonNull RenderProps renderProps, @NonNull HtmlTag tag) { | ||||
|             final ImageSize imageSize = new ImageSize(new ImageSize.Dimension(640, "px"), new ImageSize.Dimension(480, "px")); | ||||
|             ImageProps.IMAGE_SIZE.set(renderProps, imageSize); | ||||
|             ImageProps.DESTINATION.set(renderProps, "https://hey.com/1.png"); | ||||
|             return configuration.spansFactory().require(Image.class) | ||||
|                     .getSpans(configuration, renderProps); | ||||
|         } | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|         public Collection<String> supportedTags() { | ||||
|             return Collections.singleton("iframe"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -6,10 +6,14 @@ import android.widget.TextView; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import org.commonmark.internal.inline.AsteriskDelimiterProcessor; | ||||
| import org.commonmark.internal.inline.UnderscoreDelimiterProcessor; | ||||
| import org.commonmark.node.Block; | ||||
| import org.commonmark.node.BlockQuote; | ||||
| import org.commonmark.node.FencedCodeBlock; | ||||
| import org.commonmark.node.Heading; | ||||
| import org.commonmark.node.HtmlBlock; | ||||
| import org.commonmark.node.IndentedCodeBlock; | ||||
| import org.commonmark.node.ListBlock; | ||||
| import org.commonmark.node.ThematicBreak; | ||||
| import org.commonmark.parser.InlineParserFactory; | ||||
| @ -22,7 +26,9 @@ import java.util.Set; | ||||
| import io.noties.markwon.AbstractMarkwonPlugin; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.inlineparser.BackticksInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.BangInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.CloseBracketInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.HtmlInlineProcessor; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParser; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; | ||||
| import io.noties.markwon.inlineparser.OpenBracketInlineProcessor; | ||||
| @ -41,7 +47,9 @@ public class InlineParserActivity extends ActivityWithMenuOptions { | ||||
|                 .add("links_only", this::links_only) | ||||
|                 .add("disable_code", this::disable_code) | ||||
|                 .add("pluginWithDefaults", this::pluginWithDefaults) | ||||
|                 .add("pluginNoDefaults", this::pluginNoDefaults); | ||||
|                 .add("pluginNoDefaults", this::pluginNoDefaults) | ||||
|                 .add("disableHtmlInlineParser", this::disableHtmlInlineParser) | ||||
|                 .add("disableHtmlSanitize", this::disableHtmlSanitize); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -173,4 +181,67 @@ public class InlineParserActivity extends ActivityWithMenuOptions { | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void disableHtmlInlineParser() { | ||||
|         final String md = "# Html <b>disabled</b>\n\n" + | ||||
|                 "<em>emphasis <strong>strong</strong>\n\n" + | ||||
|                 "<p>paragraph <img src='hey.jpg' /></p>\n\n" + | ||||
|                 "<test></test>\n\n" + | ||||
|                 "<test>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(MarkwonInlineParserPlugin.create()) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configure(@NonNull Registry registry) { | ||||
|                         // NB! `AsteriskDelimiterProcessor` and `UnderscoreDelimiterProcessor` | ||||
|                         //  handles both emphasis and strong-emphasis nodes | ||||
|                         registry.require(MarkwonInlineParserPlugin.class, plugin -> { | ||||
|                             plugin.factoryBuilder() | ||||
|                                     .excludeInlineProcessor(HtmlInlineProcessor.class) | ||||
|                                     .excludeInlineProcessor(BangInlineProcessor.class) | ||||
|                                     .excludeInlineProcessor(OpenBracketInlineProcessor.class) | ||||
|                                     .excludeDelimiterProcessor(AsteriskDelimiterProcessor.class) | ||||
|                                     .excludeDelimiterProcessor(UnderscoreDelimiterProcessor.class); | ||||
|                         }); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
|                     public void configureParser(@NonNull Parser.Builder builder) { | ||||
|                         builder.enabledBlockTypes(new HashSet<>(Arrays.asList( | ||||
|                                 Heading.class, | ||||
| //                        HtmlBlock.class, | ||||
|                                 ThematicBreak.class, | ||||
|                                 FencedCodeBlock.class, | ||||
|                                 IndentedCodeBlock.class, | ||||
|                                 BlockQuote.class, | ||||
|                                 ListBlock.class | ||||
|                         ))); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void disableHtmlSanitize() { | ||||
|         final String md = "# Html <b>disabled</b>\n\n" + | ||||
|                 "<em>emphasis <strong>strong</strong>\n\n" + | ||||
|                 "<p>paragraph <img src='hey.jpg' /></p>\n\n" + | ||||
|                 "<test></test>\n\n" + | ||||
|                 "<test>"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @NonNull | ||||
|                     @Override | ||||
|                     public String processMarkdown(@NonNull String markdown) { | ||||
|                         return markdown | ||||
|                                 .replaceAll("<", "<") | ||||
|                                 .replaceAll(">", ">"); | ||||
|                     } | ||||
|                 }) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -68,7 +68,8 @@ public class LatexActivity extends ActivityWithMenuOptions { | ||||
|                 .add("textColor", this::textColor) | ||||
|                 .add("defaultTextColor", this::defaultTextColor) | ||||
|                 .add("inlineAndBlock", this::inlineAndBlock) | ||||
|                 .add("dark", this::dark); | ||||
|                 .add("dark", this::dark) | ||||
|                 .add("omega", this::omega); | ||||
|     } | ||||
| 
 | ||||
|     @Override | ||||
| @ -221,6 +222,18 @@ public class LatexActivity extends ActivityWithMenuOptions { | ||||
|         renderWithBlocksAndInlines(md); | ||||
|     } | ||||
| 
 | ||||
|     private void omega() { | ||||
|         final String md = "" + | ||||
|                 "# Block\n\n" + | ||||
|                 "$$\n" + | ||||
|                 "\\Omega\n" + | ||||
|                 "$$\n\n" + | ||||
|                 "# Inline\n\n" + | ||||
|                 "$$\\Omega$$"; | ||||
| 
 | ||||
|         renderWithBlocksAndInlines(md); | ||||
|     } | ||||
| 
 | ||||
|     @NonNull | ||||
|     private static String wrapLatexInSampleMarkdown(@NonNull String latex) { | ||||
|         return "" + | ||||
|  | ||||
| @ -34,6 +34,8 @@ import io.noties.markwon.MarkwonVisitor; | ||||
| import io.noties.markwon.core.CorePlugin; | ||||
| import io.noties.markwon.html.HtmlPlugin; | ||||
| import io.noties.markwon.image.ImagesPlugin; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessor; | ||||
| import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute; | ||||
| import io.noties.markwon.image.file.FileSchemeHandler; | ||||
| import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler; | ||||
| import io.noties.markwon.image.svg.SvgMediaDecoder; | ||||
| @ -42,8 +44,6 @@ import io.noties.markwon.recycler.SimpleEntry; | ||||
| import io.noties.markwon.recycler.table.TableEntry; | ||||
| import io.noties.markwon.recycler.table.TableEntryPlugin; | ||||
| import io.noties.markwon.sample.R; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessor; | ||||
| import io.noties.markwon.urlprocessor.UrlProcessorRelativeToAbsolute; | ||||
| 
 | ||||
| public class RecyclerActivity extends Activity { | ||||
| 
 | ||||
| @ -100,7 +100,7 @@ public class RecyclerActivity extends Activity { | ||||
|                 .usePlugin(new AbstractMarkwonPlugin() { | ||||
|                     @Override | ||||
|                     public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { | ||||
|                         builder.urlProcessor(new UrlProcessorInitialReadme()); | ||||
|                         builder.imageDestinationProcessor(new ImageDestinationProcessorInitialReadme()); | ||||
|                     } | ||||
| 
 | ||||
|                     @Override | ||||
| @ -182,12 +182,12 @@ public class RecyclerActivity extends Activity { | ||||
|         return out; | ||||
|     } | ||||
| 
 | ||||
|     private static class UrlProcessorInitialReadme implements UrlProcessor { | ||||
|     private static class ImageDestinationProcessorInitialReadme extends ImageDestinationProcessor { | ||||
| 
 | ||||
|         private static final String GITHUB_BASE = "https://github.com/noties/Markwon/raw/master/"; | ||||
| 
 | ||||
|         private final UrlProcessorRelativeToAbsolute processor | ||||
|                 = new UrlProcessorRelativeToAbsolute(GITHUB_BASE); | ||||
|         private final ImageDestinationProcessorRelativeToAbsolute processor | ||||
|                 = new ImageDestinationProcessorRelativeToAbsolute(GITHUB_BASE); | ||||
| 
 | ||||
|         @NonNull | ||||
|         @Override | ||||
|  | ||||
| @ -7,10 +7,11 @@ import android.widget.TextView; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import io.noties.debug.Debug; | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.ext.latex.JLatexMathPlugin; | ||||
| import io.noties.markwon.ext.tables.TablePlugin; | ||||
| import io.noties.markwon.ext.tables.TableTheme; | ||||
| import io.noties.markwon.image.ImagesPlugin; | ||||
| import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; | ||||
| import io.noties.markwon.linkify.LinkifyPlugin; | ||||
| import io.noties.markwon.sample.ActivityWithMenuOptions; | ||||
| import io.noties.markwon.sample.MenuOptions; | ||||
| @ -25,7 +26,9 @@ public class TableActivity extends ActivityWithMenuOptions { | ||||
|     public MenuOptions menuOptions() { | ||||
|         return MenuOptions.create() | ||||
|                 .add("customize", this::customize) | ||||
|                 .add("tableAndLinkify", this::tableAndLinkify); | ||||
|                 .add("tableAndLinkify", this::tableAndLinkify) | ||||
|                 .add("withImages", this::withImages) | ||||
|                 .add("withLatex", this::withLatex); | ||||
|     } | ||||
| 
 | ||||
|     private TextView textView; | ||||
| @ -86,4 +89,47 @@ public class TableActivity extends ActivityWithMenuOptions { | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void withImages() { | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 "| HEADER | HEADER |\n" + | ||||
|                 "|:----:|:----:|\n" + | ||||
|                 "|  | Build |\n" + | ||||
|                 "| Stable |  |\n" + | ||||
|                 "| BIG |  |\n" + | ||||
|                 "\n"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(ImagesPlugin.create()) | ||||
|                 .usePlugin(TablePlugin.create(this)) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| 
 | ||||
|     private void withLatex() { | ||||
| 
 | ||||
|         String latex = "\\begin{array}{cc}"; | ||||
|         latex += "\\fbox{\\text{A framed box with \\textdbend}}&\\shadowbox{\\text{A shadowed box}}\\cr"; | ||||
|         latex += "\\doublebox{\\text{A double framed box}}&\\ovalbox{\\text{An oval framed box}}\\cr"; | ||||
|         latex += "\\end{array}"; | ||||
| 
 | ||||
|         final String md = "" + | ||||
|                 "| HEADER | HEADER |\n" + | ||||
|                 "|:----:|:----:|\n" + | ||||
|                 "|  | Build |\n" + | ||||
|                 "| Stable |  |\n" + | ||||
|                 "| BIG | $$" + latex + "$$ |\n" + | ||||
|                 "\n"; | ||||
| 
 | ||||
|         final Markwon markwon = Markwon.builder(this) | ||||
|                 .usePlugin(MarkwonInlineParserPlugin.create()) | ||||
|                 .usePlugin(ImagesPlugin.create()) | ||||
|                 .usePlugin(JLatexMathPlugin.create(textView.getTextSize(), builder -> builder.inlinesEnabled(true))) | ||||
|                 .usePlugin(TablePlugin.create(this)) | ||||
|                 .build(); | ||||
| 
 | ||||
|         markwon.setMarkdown(textView, md); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -14,6 +14,7 @@ | ||||
|         android:textAppearance="?android:attr/textAppearanceMedium" | ||||
|         android:textColor="#000" | ||||
|         android:textSize="16sp" | ||||
|         android:padding="8dip" | ||||
|         tools:text="whatever" /> | ||||
| 
 | ||||
| </ScrollView> | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Dimitry
						Dimitry