V2.0.1 fixed (#83)

* `SpannableMarkdownVisitor` Rename blockQuoteIndent to blockIndent
* Fixed block new lines logic for block quote and paragraph (#82)
* AsyncDrawable fix no dimensions bug (#81)
* Update SpannableTheme to use Px instead of Dimension annotation
* Allow TaskListSpan isDone mutation 
* Updated commonmark-java to 0.12.1
* Add OrderedListItemSpan measure utility method (#78)
* Add SpannableBuilder#getSpans method
* Fix DataUri scheme handler in image-loader (#74)
* Introduced a "copy" builder for SpannableThem
  Thanks @c-b-h 🙌
This commit is contained in:
Dimitry 2018-12-10 14:58:40 +03:00 committed by GitHub
parent e0563dca43
commit 26bfb7f2e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 954 additions and 100 deletions

View File

@ -9,7 +9,7 @@ android:
- platform-tools - platform-tools
- tools - tools
- build-tools-27.0.3 - build-tools-28.0.3
- android-27 - android-27
branches: branches:

View File

@ -7,6 +7,8 @@
[![markwon-syntax-highlight](https://img.shields.io/maven-central/v/ru.noties/markwon-syntax-highlight.svg?label=markwon-syntax-highlight)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax-highlight%22) [![markwon-syntax-highlight](https://img.shields.io/maven-central/v/ru.noties/markwon-syntax-highlight.svg?label=markwon-syntax-highlight)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax-highlight%22)
[![markwon-view](https://img.shields.io/maven-central/v/ru.noties/markwon-view.svg?label=markwon-view)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-view%22) [![markwon-view](https://img.shields.io/maven-central/v/ru.noties/markwon-view.svg?label=markwon-view)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-view%22)
[![Build Status](https://travis-ci.org/noties/Markwon.svg?branch=master)](https://travis-ci.org/noties/Markwon)
**Markwon** is a markdown library for Android. It parses markdown **Markwon** is a markdown library for Android. It parses markdown
following [commonmark-spec] with the help of amazing [commonmark-java] following [commonmark-spec] with the help of amazing [commonmark-java]
library and renders result as _Android-native_ Spannables. **No HTML** library and renders result as _Android-native_ Spannables. **No HTML**
@ -91,6 +93,14 @@ Please visit [documentation] web-site for reference
[documentation]: https://noties.github.io/Markwon [documentation]: https://noties.github.io/Markwon
---
## Applications using Markwon
* [Partiko](https://partiko.app)
* [FairNote Notepad](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote)
--- ---
# Demo # Demo
@ -277,12 +287,6 @@ ___
Underscores (`_`) Underscores (`_`)
---
## Applications using Markwon
* [FairNote Notepad](https://play.google.com/store/apps/details?id=com.rgiskard.fairnote)
## License ## License

View File

@ -36,7 +36,6 @@ dependencies {
implementation it['okhttp'] implementation it['okhttp']
implementation it['prism4j'] implementation it['prism4j']
implementation it['debug'] implementation it['debug']
implementation it['better-link-movement']
implementation it['dagger'] implementation it['dagger']
} }

View File

@ -11,7 +11,6 @@ import android.widget.TextView;
import javax.inject.Inject; import javax.inject.Inject;
import me.saket.bettermovementmethod.BetterLinkMovementMethod;
import ru.noties.debug.Debug; import ru.noties.debug.Debug;
public class MainActivity extends Activity { public class MainActivity extends Activity {
@ -71,7 +70,7 @@ public class MainActivity extends Activity {
@Override @Override
public void onMarkdownReady(CharSequence markdown) { public void onMarkdownReady(CharSequence markdown) {
Markwon.setText(textView, markdown, BetterLinkMovementMethod.getInstance()); Markwon.setText(textView, markdown);
gifProcessor.process(textView); gifProcessor.process(textView);

View File

@ -1,10 +1,10 @@
buildscript { buildscript {
repositories { repositories {
jcenter()
google() google()
jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.0.1' classpath 'com.android.tools.build:gradle:3.2.1'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0'
} }
} }
@ -14,8 +14,8 @@ allprojects {
if (project.hasProperty('LOCAL_MAVEN_URL')) { if (project.hasProperty('LOCAL_MAVEN_URL')) {
maven { url LOCAL_MAVEN_URL } maven { url LOCAL_MAVEN_URL }
} }
jcenter()
google() google()
jcenter()
} }
version = VERSION_NAME version = VERSION_NAME
group = GROUP group = GROUP
@ -26,7 +26,7 @@ task clean(type: Delete) {
} }
task wrapper(type: Wrapper) { task wrapper(type: Wrapper) {
gradleVersion '4.8.1' gradleVersion '4.10.2'
distributionType 'all' distributionType 'all'
} }
@ -40,8 +40,9 @@ if (hasProperty('local')) {
ext { ext {
// NB, updating build-tools or compile-sdk will require updating Travis config (.travis.yml)
config = [ config = [
'build-tools' : '27.0.3', 'build-tools' : '28.0.3',
'compile-sdk' : 27, 'compile-sdk' : 27,
'target-sdk' : 27, 'target-sdk' : 27,
'min-sdk' : 16, 'min-sdk' : 16,
@ -49,7 +50,7 @@ ext {
] ]
final def supportVersion = '27.1.1' final def supportVersion = '27.1.1'
final def commonMarkVersion = '0.11.0' final def commonMarkVersion = '0.12.1'
final def daggerVersion = '2.10' final def daggerVersion = '2.10'
deps = [ deps = [
@ -63,7 +64,6 @@ ext {
'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0', 'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0',
'prism4j' : 'ru.noties:prism4j:1.1.0', 'prism4j' : 'ru.noties:prism4j:1.1.0',
'debug' : 'ru.noties:debug:3.0.0@jar', 'debug' : 'ru.noties:debug:3.0.0@jar',
'better-link-movement' : 'me.saket:better-link-movement-method:2.2.0',
'dagger' : "com.google.dagger:dagger:$daggerVersion" 'dagger' : "com.google.dagger:dagger:$daggerVersion"
] ]

View File

@ -82,6 +82,9 @@ textView.setMovementMethod(LinkMovementMethod.getInstance());
Markwon.unscheduleDrawables(textView); Markwon.unscheduleDrawables(textView);
Markwon.unscheduleTableRows(textView); Markwon.unscheduleTableRows(textView);
// @since 2.0.1 we must measure ordered list items _before_ they are rendered
OrderedListItemSpan.measure(view, text);
textView.setText(text); textView.setText(text);
Markwon.scheduleDrawables(textView); Markwon.scheduleDrawables(textView);

View File

@ -6,7 +6,7 @@ org.gradle.configureondemand=true
android.enableBuildCache=true android.enableBuildCache=true
android.buildCacheDir=build/pre-dex-cache android.buildCacheDir=build/pre-dex-cache
VERSION_NAME=2.0.0 VERSION_NAME=2.0.1-SNAPSHOT
GROUP=ru.noties GROUP=ru.noties
POM_DESCRIPTION=Markwon POM_DESCRIPTION=Markwon

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.8.1-all.zip

View File

@ -3,7 +3,6 @@ package ru.noties.markwon.il;
import android.net.Uri; import android.net.Uri;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.text.TextUtils;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.util.Collection; import java.util.Collection;
@ -19,7 +18,7 @@ public class DataUriSchemeHandler extends SchemeHandler {
return new DataUriSchemeHandler(DataUriParser.create(), DataUriDecoder.create()); return new DataUriSchemeHandler(DataUriParser.create(), DataUriDecoder.create());
} }
private static final String START = "data://"; private static final String START = "data:";
private final DataUriParser uriParser; private final DataUriParser uriParser;
private final DataUriDecoder uriDecoder; private final DataUriDecoder uriDecoder;
@ -38,7 +37,12 @@ public class DataUriSchemeHandler extends SchemeHandler {
return null; return null;
} }
final String part = raw.substring(START.length()); String part = raw.substring(START.length());
// this part is added to support `data://` with which this functionality was released
if (part.startsWith("//")) {
part = part.substring(2);
}
final DataUri dataUri = uriParser.parse(part); final DataUri dataUri = uriParser.parse(part);
if (dataUri == null) { if (dataUri == null) {

View File

@ -71,6 +71,33 @@ public class DataUriSchemeHandlerTest {
} }
} }
@Test
public void correct_real() {
final class Item {
final String contentType;
final String data;
Item(String contentType, String data) {
this.contentType = contentType;
this.data = data;
}
}
final Map<String, Item> expected = new HashMap<String, Item>() {{
put("data:text/plain;,123", new Item("text/plain", "123"));
put("data:image/svg+xml;base64,MTIz", new Item("image/svg+xml", "123"));
}};
for (Map.Entry<String, Item> entry : expected.entrySet()) {
final ImageItem item = handler.handle(entry.getKey(), Uri.parse(entry.getKey()));
assertNotNull(entry.getKey(), item);
assertEquals(entry.getKey(), entry.getValue().contentType, item.contentType());
assertEquals(entry.getKey(), entry.getValue().data, readStream(item.inputStream()));
}
}
@NonNull @NonNull
private static String readStream(@NonNull InputStream stream) { private static String readStream(@NonNull InputStream stream) {
try { try {

View File

@ -15,6 +15,7 @@ import org.commonmark.parser.Parser;
import java.util.Arrays; import java.util.Arrays;
import ru.noties.markwon.renderer.SpannableRenderer; import ru.noties.markwon.renderer.SpannableRenderer;
import ru.noties.markwon.spans.OrderedListItemSpan;
import ru.noties.markwon.tasklist.TaskListExtension; import ru.noties.markwon.tasklist.TaskListExtension;
@SuppressWarnings({"WeakerAccess", "unused"}) @SuppressWarnings({"WeakerAccess", "unused"})
@ -100,6 +101,12 @@ public abstract class Markwon {
unscheduleDrawables(view); unscheduleDrawables(view);
unscheduleTableRows(view); unscheduleTableRows(view);
// @since 2.0.1 we must measure ordered-list-item-spans before applying text to a TextView.
// if markdown has a lot of ordered list items (or text size is relatively big, or block-margin
// is relatively small) then this list won't be rendered properly: it will take correct
// layout (width and margin) but will be clipped if margin is not _consistent_ between calls.
OrderedListItemSpan.measure(view, text);
// update movement method (for links to be clickable) // update movement method (for links to be clickable)
view.setMovementMethod(movementMethod); view.setMovementMethod(movementMethod);
view.setText(text); view.setText(text);

View File

@ -2,12 +2,16 @@ package ru.noties.markwon;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque; import java.util.Deque;
import java.util.Iterator; import java.util.Iterator;
import java.util.List;
/** /**
* This class is used to _revert_ order of applied spans. Original SpannableStringBuilder * This class is used to _revert_ order of applied spans. Original SpannableStringBuilder
@ -44,7 +48,9 @@ public class SpannableBuilder implements Appendable, CharSequence {
} }
} }
private static boolean isPositionValid(int length, int start, int end) { // @since 2.0.1 package-private visibility for testing
@VisibleForTesting
static boolean isPositionValid(int length, int start, int end) {
return end > start return end > start
&& start >= 0 && start >= 0
&& end <= length; && end <= length;
@ -157,7 +163,93 @@ public class SpannableBuilder implements Appendable, CharSequence {
*/ */
@Override @Override
public CharSequence subSequence(int start, int end) { public CharSequence subSequence(int start, int end) {
return builder.subSequence(start, end);
final CharSequence out;
// @since 2.0.1 we copy spans to resulting subSequence
final List<Span> spans = getSpans(start, end);
if (spans.isEmpty()) {
out = builder.subSequence(start, end);
} else {
// we should not be SpannableStringBuilderReversed here
final SpannableStringBuilder builder = new SpannableStringBuilder(this.builder.subSequence(start, end));
final int length = builder.length();
int s;
int e;
for (Span span : spans) {
// we should limit start/end to resulting subSequence length
//
// for example, originally it was 5-7 and range 5-7 requested
// span should have 0-2
//
// if a span was fully including resulting subSequence it's start and
// end must be within 0..length bounds
s = Math.max(0, span.start - start);
e = Math.max(length, s + (span.end - span.start));
builder.setSpan(
span.what,
s,
e,
span.flags
);
}
out = builder;
}
return out;
}
/**
* This method will return all {@link Span} spans that <em>overlap</em> specified range,
* so if for example a 1..9 range is specified some spans might have 0..6 or 0..10 start/end ranges.
* NB spans are returned in reversed order (no in order that we store them internally)
*
* @since 2.0.1
*/
@NonNull
public List<Span> getSpans(int start, int end) {
final int length = length();
if (!isPositionValid(length, start, end)) {
// we might as well throw here
return Collections.emptyList();
}
// all requested
if (start == 0
&& length == end) {
// but also copy (do not allow external modification)
final List<Span> list = new ArrayList<>(spans);
Collections.reverse(list);
return Collections.unmodifiableList(list);
}
final List<Span> list = new ArrayList<>(0);
final Iterator<Span> iterator = spans.descendingIterator();
Span span;
while (iterator.hasNext()) {
span = iterator.next();
// we must execute 2 checks: if overlap with specified range or fully include it
// if span.start is >= range.start -> check if it's before range.end
// if span.end is <= end -> check if it's after range.start
if (
(span.start >= start && span.start < end)
|| (span.end <= end && span.end > start)
|| (span.start < start && span.end > end)) {
list.add(span);
}
}
return Collections.unmodifiableList(list);
} }
public char lastChar() { public char lastChar() {
@ -173,7 +265,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
final int end = length(); final int end = length();
// as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String // as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String
final SpannableStringBuilderImpl impl = new SpannableStringBuilderImpl(builder.subSequence(start, end)); final SpannableStringBuilderReversed impl = new SpannableStringBuilderReversed(builder.subSequence(start, end));
final Iterator<Span> iterator = spans.iterator(); final Iterator<Span> iterator = spans.iterator();
@ -206,7 +298,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
/** /**
* Simple method to create a SpannableStringBuilder, which is created anyway. Unlike {@link #text()} * Simple method to create a SpannableStringBuilder, which is created anyway. Unlike {@link #text()}
* method which returns the same SpannableStringBuilder there is no need to cast the resulting * method which returns the same SpannableStringBuilder there is no need to cast the resulting
* CharSequence * CharSequence and makes the thing more explicit
* *
* @since 2.0.0 * @since 2.0.0
*/ */
@ -222,13 +314,15 @@ public class SpannableBuilder implements Appendable, CharSequence {
// as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String // as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String
final SpannableStringBuilderImpl impl = new SpannableStringBuilderImpl(builder); final SpannableStringBuilderReversed reversed = new SpannableStringBuilderReversed(builder);
// NB, as e are using Deque -> iteration will be started with last element
// so, spans will be appearing in the for loop in reverse order
for (Span span : spans) { for (Span span : spans) {
impl.setSpan(span.what, span.start, span.end, span.flags); reversed.setSpan(span.what, span.start, span.end, span.flags);
} }
return impl; return reversed;
} }
private void copySpans(final int index, @Nullable CharSequence cs) { private void copySpans(final int index, @Nullable CharSequence cs) {
@ -239,7 +333,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
if (cs instanceof Spanned) { if (cs instanceof Spanned) {
final Spanned spanned = (Spanned) cs; final Spanned spanned = (Spanned) cs;
final boolean reverse = spanned instanceof SpannedReversed; final boolean reversed = spanned instanceof SpannableStringBuilderReversed;
final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
final int length = spans != null final int length = spans != null
@ -247,7 +341,7 @@ public class SpannableBuilder implements Appendable, CharSequence {
: 0; : 0;
if (length > 0) { if (length > 0) {
if (reverse) { if (reversed) {
Object o; Object o;
for (int i = length - 1; i >= 0; i--) { for (int i = length - 1; i >= 0; i--) {
o = spans[i]; o = spans[i];
@ -274,12 +368,15 @@ public class SpannableBuilder implements Appendable, CharSequence {
} }
} }
static class Span { /**
* @since 2.0.1 made public in order to be returned from `getSpans` method, initially added in 1.0.1
*/
public static class Span {
final Object what; public final Object what;
int start; public int start;
int end; public int end;
final int flags; public final int flags;
Span(@NonNull Object what, int start, int end, int flags) { Span(@NonNull Object what, int start, int end, int flags) {
this.what = what; this.what = what;
@ -288,4 +385,13 @@ public class SpannableBuilder implements Appendable, CharSequence {
this.flags = flags; this.flags = flags;
} }
} }
/**
* @since 2.0.1 made inner class of {@link SpannableBuilder}, initially added in 1.0.1
*/
static class SpannableStringBuilderReversed extends SpannableStringBuilder {
SpannableStringBuilderReversed(CharSequence text) {
super(text);
}
}
} }

View File

@ -51,6 +51,14 @@ public class SpannableConfiguration {
this.htmlAllowNonClosedTags = builder.htmlAllowNonClosedTags; this.htmlAllowNonClosedTags = builder.htmlAllowNonClosedTags;
} }
/**
* Returns a new builder based on this configuration
*/
@NonNull
public Builder newBuilder(@NonNull Context context) {
return new Builder(context, this);
}
@NonNull @NonNull
public SpannableTheme theme() { public SpannableTheme theme() {
return theme; return theme;
@ -138,6 +146,21 @@ public class SpannableConfiguration {
this.context = context; this.context = context;
} }
Builder(@NonNull Context context, @NonNull SpannableConfiguration configuration) {
this(context);
this.theme = configuration.theme;
this.asyncDrawableLoader = configuration.asyncDrawableLoader;
this.syntaxHighlight = configuration.syntaxHighlight;
this.linkResolver = configuration.linkResolver;
this.urlProcessor = configuration.urlProcessor;
this.imageSizeResolver = configuration.imageSizeResolver;
this.factory = configuration.factory;
this.softBreakAddsNewLine = configuration.softBreakAddsNewLine;
this.htmlParser = configuration.htmlParser;
this.htmlRenderer = configuration.htmlRenderer;
this.htmlAllowNonClosedTags = configuration.htmlAllowNonClosedTags;
}
@NonNull @NonNull
public Builder theme(@NonNull SpannableTheme theme) { public Builder theme(@NonNull SpannableTheme theme) {
this.theme = theme; this.theme = theme;

View File

@ -1,13 +0,0 @@
package ru.noties.markwon;
import android.text.SpannableStringBuilder;
/**
* @since 1.0.1
*/
class SpannableStringBuilderImpl extends SpannableStringBuilder implements SpannedReversed {
SpannableStringBuilderImpl(CharSequence text) {
super(text);
}
}

View File

@ -1,9 +0,0 @@
package ru.noties.markwon;
import android.text.Spanned;
/**
* @since 1.0.1
*/
interface SpannedReversed extends Spanned {
}

View File

@ -56,7 +56,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
private final SpannableTheme theme; private final SpannableTheme theme;
private final SpannableFactory factory; private final SpannableFactory factory;
private int blockQuoteIndent; private int blockIndent;
private int listLevel; private int listLevel;
private List<TableRowSpan.Cell> pendingTableRow; private List<TableRowSpan.Cell> pendingTableRow;
@ -105,25 +105,20 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
public void visit(BlockQuote blockQuote) { public void visit(BlockQuote blockQuote) {
newLine(); newLine();
if (blockQuoteIndent != 0) {
builder.append('\n');
}
final int length = builder.length(); final int length = builder.length();
blockQuoteIndent += 1; blockIndent += 1;
visitChildren(blockQuote); visitChildren(blockQuote);
setSpan(length, factory.blockQuote(theme)); setSpan(length, factory.blockQuote(theme));
blockQuoteIndent -= 1; blockIndent -= 1;
if (hasNext(blockQuote)) { if (hasNext(blockQuote)) {
newLine(); newLine();
if (blockQuoteIndent == 0) { forceNewLine();
builder.append('\n');
}
} }
} }
@ -180,7 +175,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(node)) { if (hasNext(node)) {
newLine(); newLine();
builder.append('\n'); forceNewLine();
} }
} }
@ -202,9 +197,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(node)) { if (hasNext(node)) {
newLine(); newLine();
if (listLevel == 0 && blockQuoteIndent == 0) { forceNewLine();
builder.append('\n');
}
} }
} }
@ -213,7 +206,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
final int length = builder.length(); final int length = builder.length();
blockQuoteIndent += 1; blockIndent += 1;
listLevel += 1; listLevel += 1;
final Node parent = listItem.getParent(); final Node parent = listItem.getParent();
@ -236,7 +229,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
setSpan(length, factory.bulletListItem(theme, listLevel - 1)); setSpan(length, factory.bulletListItem(theme, listLevel - 1));
} }
blockQuoteIndent -= 1; blockIndent -= 1;
listLevel -= 1; listLevel -= 1;
if (hasNext(listItem)) { if (hasNext(listItem)) {
@ -256,7 +249,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(thematicBreak)) { if (hasNext(thematicBreak)) {
newLine(); newLine();
builder.append('\n'); forceNewLine();
} }
} }
@ -272,7 +265,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(heading)) { if (hasNext(heading)) {
newLine(); newLine();
// after heading we add another line anyway (no additional checks) // after heading we add another line anyway (no additional checks)
builder.append('\n'); forceNewLine();
} }
} }
@ -298,13 +291,14 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
public void visit(CustomBlock customBlock) { public void visit(CustomBlock customBlock) {
if (customBlock instanceof TaskListBlock) { if (customBlock instanceof TaskListBlock) {
blockQuoteIndent += 1;
blockIndent += 1;
visitChildren(customBlock); visitChildren(customBlock);
blockQuoteIndent -= 1; blockIndent -= 1;
if (hasNext(customBlock)) { if (hasNext(customBlock)) {
newLine(); newLine();
builder.append('\n'); forceNewLine();
} }
} else { } else {
@ -329,17 +323,17 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
final int length = builder.length(); final int length = builder.length();
blockQuoteIndent += listItem.indent(); blockIndent += listItem.indent();
visitChildren(customNode); visitChildren(customNode);
setSpan(length, factory.taskListItem(theme, blockQuoteIndent, listItem.done())); setSpan(length, factory.taskListItem(theme, blockIndent, listItem.done()));
if (hasNext(customNode)) { if (hasNext(customNode)) {
newLine(); newLine();
} }
blockQuoteIndent -= listItem.indent(); blockIndent -= listItem.indent();
} else if (!handleTableNodes(customNode)) { } else if (!handleTableNodes(customNode)) {
super.visit(customNode); super.visit(customNode);
@ -358,7 +352,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(node)) { if (hasNext(node)) {
newLine(); newLine();
builder.append('\n'); forceNewLine();
} }
} else if (node instanceof TableRow || node instanceof TableHead) { } else if (node instanceof TableRow || node instanceof TableHead) {
@ -445,9 +439,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
if (hasNext(paragraph) && !inTightList) { if (hasNext(paragraph) && !inTightList) {
newLine(); newLine();
if (blockQuoteIndent == 0) { forceNewLine();
builder.append('\n');
}
} }
} }
@ -518,6 +510,10 @@ public class SpannableMarkdownVisitor extends AbstractVisitor {
} }
} }
private void forceNewLine() {
builder.append('\n');
}
private boolean isInTightList(Paragraph paragraph) { private boolean isInTightList(Paragraph paragraph) {
final Node parent = paragraph.getParent(); final Node parent = paragraph.getParent();
if (parent != null) { if (parent != null) {

View File

@ -33,6 +33,9 @@ public class AsyncDrawable extends Drawable {
private int canvasWidth; private int canvasWidth;
private float textSize; private float textSize;
// @since 2.0.1 for use-cases when image is loaded faster than span is drawn and knows canvas width
private boolean waitingForDimensions;
/** /**
* @since 1.0.1 * @since 1.0.1
*/ */
@ -98,6 +101,19 @@ public class AsyncDrawable extends Drawable {
this.result = result; this.result = result;
this.result.setCallback(callback); this.result.setCallback(callback);
initBounds();
}
private void initBounds() {
if (canvasWidth == 0) {
// we still have no bounds - wait for them
waitingForDimensions = true;
return;
}
waitingForDimensions = false;
final Rect bounds = resolveBounds(); final Rect bounds = resolveBounds();
result.setBounds(bounds); result.setBounds(bounds);
setBounds(bounds); setBounds(bounds);
@ -112,6 +128,10 @@ public class AsyncDrawable extends Drawable {
public void initWithKnownDimensions(int width, float textSize) { public void initWithKnownDimensions(int width, float textSize) {
this.canvasWidth = width; this.canvasWidth = width;
this.textSize = textSize; this.textSize = textSize;
if (waitingForDimensions) {
initBounds();
}
} }
@Override @Override

View File

@ -4,10 +4,44 @@ import android.graphics.Canvas;
import android.graphics.Paint; import android.graphics.Paint;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.text.Layout; import android.text.Layout;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.style.LeadingMarginSpan; import android.text.style.LeadingMarginSpan;
import android.widget.TextView;
public class OrderedListItemSpan implements LeadingMarginSpan { public class OrderedListItemSpan implements LeadingMarginSpan {
/**
* Process supplied `text` argument and supply TextView paint to all OrderedListItemSpans
* in order for them to measure number.
* <p>
* NB, this method must be called <em>before</em> setting text to a TextView (`TextView#setText`
* internally can trigger new Layout creation which will ask for leading margins right away)
*
* @param textView to which markdown will be applied
* @param text parsed markdown to process
* @since 2.0.1
*/
public static void measure(@NonNull TextView textView, @NonNull CharSequence text) {
if (!(text instanceof Spanned)) {
// nothing to do here
return;
}
final OrderedListItemSpan[] spans = ((Spanned) text).getSpans(
0,
text.length(),
OrderedListItemSpan.class);
if (spans != null) {
final TextPaint paint = textView.getPaint();
for (OrderedListItemSpan span : spans) {
span.margin = (int) (paint.measureText(span.number) + .5F);
}
}
}
private final SpannableTheme theme; private final SpannableTheme theme;
private final String number; private final String number;
private final Paint paint = ObjectsPool.paint(); private final Paint paint = ObjectsPool.paint();
@ -27,8 +61,8 @@ public class OrderedListItemSpan implements LeadingMarginSpan {
@Override @Override
public int getLeadingMargin(boolean first) { public int getLeadingMargin(boolean first) {
// @since 1.0.3 // @since 2.0.1 we return maximum value of both (now we should measure number before)
return margin > 0 ? margin : theme.getBlockMargin(); return Math.max(margin, theme.getBlockMargin());
} }
@Override @Override
@ -44,11 +78,16 @@ public class OrderedListItemSpan implements LeadingMarginSpan {
theme.applyListItemStyle(paint); theme.applyListItemStyle(paint);
final int numberWidth = (int) (p.measureText(number) + .5F); // if we could force usage of #measure method then we might want skip this measuring here
// but this won't hold against new values that a TextView can receive (new text size for
// example...)
final int numberWidth = (int) (paint.measureText(number) + .5F);
// @since 1.0.3 // @since 1.0.3
int width = theme.getBlockMargin(); int width = theme.getBlockMargin();
if (numberWidth > width) { if (numberWidth > width) {
// let's keep this logic here in case a user decided not to call #measure and is fine
// with current implementation
width = numberWidth; width = numberWidth;
margin = numberWidth; margin = numberWidth;
} else { } else {

View File

@ -12,6 +12,7 @@ import android.support.annotation.FloatRange;
import android.support.annotation.IntRange; import android.support.annotation.IntRange;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.annotation.Px;
import android.support.annotation.Size; import android.support.annotation.Size;
import android.text.TextPaint; import android.text.TextPaint;
import android.util.TypedValue; import android.util.TypedValue;
@ -600,13 +601,13 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder blockMargin(@Dimension int blockMargin) { public Builder blockMargin(@Px int blockMargin) {
this.blockMargin = blockMargin; this.blockMargin = blockMargin;
return this; return this;
} }
@NonNull @NonNull
public Builder blockQuoteWidth(@Dimension int blockQuoteWidth) { public Builder blockQuoteWidth(@Px int blockQuoteWidth) {
this.blockQuoteWidth = blockQuoteWidth; this.blockQuoteWidth = blockQuoteWidth;
return this; return this;
} }
@ -625,13 +626,13 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder bulletListItemStrokeWidth(@Dimension int bulletListItemStrokeWidth) { public Builder bulletListItemStrokeWidth(@Px int bulletListItemStrokeWidth) {
this.bulletListItemStrokeWidth = bulletListItemStrokeWidth; this.bulletListItemStrokeWidth = bulletListItemStrokeWidth;
return this; return this;
} }
@NonNull @NonNull
public Builder bulletWidth(@Dimension int bulletWidth) { public Builder bulletWidth(@Px int bulletWidth) {
this.bulletWidth = bulletWidth; this.bulletWidth = bulletWidth;
return this; return this;
} }
@ -668,7 +669,7 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder codeMultilineMargin(@Dimension int codeMultilineMargin) { public Builder codeMultilineMargin(@Px int codeMultilineMargin) {
this.codeMultilineMargin = codeMultilineMargin; this.codeMultilineMargin = codeMultilineMargin;
return this; return this;
} }
@ -680,13 +681,13 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder codeTextSize(@Dimension int codeTextSize) { public Builder codeTextSize(@Px int codeTextSize) {
this.codeTextSize = codeTextSize; this.codeTextSize = codeTextSize;
return this; return this;
} }
@NonNull @NonNull
public Builder headingBreakHeight(@Dimension int headingBreakHeight) { public Builder headingBreakHeight(@Px int headingBreakHeight) {
this.headingBreakHeight = headingBreakHeight; this.headingBreakHeight = headingBreakHeight;
return this; return this;
} }
@ -733,13 +734,13 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder thematicBreakHeight(@Dimension int thematicBreakHeight) { public Builder thematicBreakHeight(@Px int thematicBreakHeight) {
this.thematicBreakHeight = thematicBreakHeight; this.thematicBreakHeight = thematicBreakHeight;
return this; return this;
} }
@NonNull @NonNull
public Builder tableCellPadding(@Dimension int tableCellPadding) { public Builder tableCellPadding(@Px int tableCellPadding) {
this.tableCellPadding = tableCellPadding; this.tableCellPadding = tableCellPadding;
return this; return this;
} }
@ -751,7 +752,7 @@ public class SpannableTheme {
} }
@NonNull @NonNull
public Builder tableBorderWidth(@Dimension int tableBorderWidth) { public Builder tableBorderWidth(@Px int tableBorderWidth) {
this.tableBorderWidth = tableBorderWidth; this.tableBorderWidth = tableBorderWidth;
return this; return this;
} }
@ -775,7 +776,7 @@ public class SpannableTheme {
* @since 1.1.1 * @since 1.1.1
*/ */
@NonNull @NonNull
public Builder tableHeaderRowBackgroundColor(int tableHeaderRowBackgroundColor) { public Builder tableHeaderRowBackgroundColor(@ColorInt int tableHeaderRowBackgroundColor) {
this.tableHeaderRowBackgroundColor = tableHeaderRowBackgroundColor; this.tableHeaderRowBackgroundColor = tableHeaderRowBackgroundColor;
return this; return this;
} }

View File

@ -18,7 +18,9 @@ public class TaskListSpan implements LeadingMarginSpan {
private final SpannableTheme theme; private final SpannableTheme theme;
private final int blockIndent; private final int blockIndent;
private final boolean isDone;
// @since 2.0.1 field is NOT final (to allow mutation)
private boolean isDone;
public TaskListSpan(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) { public TaskListSpan(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) {
this.theme = theme; this.theme = theme;
@ -26,6 +28,23 @@ public class TaskListSpan implements LeadingMarginSpan {
this.isDone = isDone; this.isDone = isDone;
} }
/**
* @since 2.0.1
*/
public boolean isDone() {
return isDone;
}
/**
* Update {@link #isDone} property of this span. Please note that this is merely a visual change
* which is not changing underlying text in any means.
*
* @since 2.0.1
*/
public void setDone(boolean isDone) {
this.isDone = isDone;
}
@Override @Override
public int getLeadingMargin(boolean first) { public int getLeadingMargin(boolean first) {
return theme.getBlockMargin() * blockIndent; return theme.getBlockMargin() * blockIndent;

View File

@ -0,0 +1,359 @@
package ru.noties.markwon;
import android.support.annotation.NonNull;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.Arrays;
import java.util.List;
import ix.Ix;
import ix.IxFunction;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static ru.noties.markwon.SpannableBuilder.isPositionValid;
import static ru.noties.markwon.SpannableBuilder.setSpans;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class SpannableBuilderTest {
private SpannableBuilder builder;
@Before
public void before() {
builder = new SpannableBuilder();
}
@Test
public void position_invalid() {
final Position[] positions = {
Position.of(0, 0, 0),
Position.of(-1, -1, -1),
Position.of(0, -1, 1),
Position.of(1, 1, 1),
Position.of(0, 0, 10),
Position.of(10, 10, 0),
Position.of(10, 5, 2),
Position.of(5, 1, 1)
};
for (Position position : positions) {
assertFalse(position.toString(), isPositionValid(position.length, position.start, position.end));
}
}
@Test
public void position_valid() {
final Position[] positions = {
Position.of(1, 0, 1),
Position.of(2, 0, 1),
Position.of(2, 1, 2),
Position.of(10, 0, 10),
Position.of(7, 6, 7)
};
for (Position position : positions) {
assertTrue(position.toString(), isPositionValid(position.length, position.start, position.end));
}
}
@Test
public void get_spans() {
// all spans that overlap with specified range or spans that include it fully -> should be returned
final int length = 10;
for (int i = 0; i < length; i++) {
builder.append(String.valueOf(i));
}
for (int start = 0, end = length - 1; start < end; start++, end--) {
builder.setSpan("" + start + "-" + end, start, end);
}
// all (simple check that spans that take range greater that supplied range are also returned)
final List<String> all = Arrays.asList("0-9", "1-8", "2-7", "3-6", "4-5");
for (int start = 0, end = length - 1; start < end; start++, end--) {
assertEquals(
"" + start + "-" + end,
all,
getSpans(start, end)
);
}
assertEquals(
"1-3",
Arrays.asList("0-9", "1-8", "2-7"),
getSpans(1, 3)
);
assertEquals(
"1-10",
all,
getSpans(1, 10)
);
assertEquals(
"5-10",
Arrays.asList("0-9", "1-8", "2-7", "3-6"),
getSpans(5, 10)
);
assertEquals(
"7-10",
Arrays.asList("0-9", "1-8"),
getSpans(7, 10)
);
}
@Test
public void get_spans_out_of_range() {
// let's test that if span.start >= range.start -> it will be less than range.end
// if span.end <= end -> it will be greater than range.start
for (int i = 0; i < 10; i++) {
builder.append(String.valueOf(i));
builder.setSpan("" + i + "-" + (i + 1), i, i + 1);
}
assertEquals(10, getSpans(0, 10).size());
// so
// 0-1
// 1-2
// 2-3
// etc
//noinspection ArraysAsListWithZeroOrOneArgument
assertEquals(
"0-1",
Arrays.asList("0-1"),
getSpans(0, 1)
);
assertEquals(
"1-5",
Arrays.asList("1-2", "2-3", "3-4", "4-5"),
getSpans(1, 5)
);
}
@NonNull
private List<String> getSpans(int start, int end) {
return Ix.from(builder.getSpans(start, end))
.map(new IxFunction<SpannableBuilder.Span, String>() {
@Override
public String apply(SpannableBuilder.Span span) {
return (String) span.what;
}
})
.toList();
}
@Test
public void set_spans_position_invalid() {
// if supplied position is invalid, no spans should be added
builder.append('0');
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
setSpans(builder, new Object(), -1, -1);
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
}
@Test
public void set_spans_single() {
// single span as `spans` argument correctly added
builder.append('0');
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
final Object span = new Object();
setSpans(builder, span, 0, 1);
final List<SpannableBuilder.Span> spans = builder.getSpans(0, builder.length());
assertEquals(1, spans.size());
assertEquals(span, spans.get(0).what);
}
@Test
public void set_spans_array_detected() {
// if supplied `spans` argument is an array -> it should be expanded
builder.append('0');
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
final Object[] spans = {
new Object(),
new Object(),
new Object()
};
setSpans(builder, spans, 0, 1);
final List<SpannableBuilder.Span> actual = builder.getSpans(0, builder.length());
assertEquals(spans.length, actual.size());
for (int i = 0, length = spans.length; i < length; i++) {
assertEquals(spans[i], actual.get(i).what);
}
}
@Test
public void set_spans_array_of_arrays() {
// if array of arrays is supplied -> it won't be expanded to single elements
builder.append('0');
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
final Object[] spans = {
new Object[]{
new Object(), new Object()
},
new Object[]{
new Object(), new Object(), new Object()
}
};
setSpans(builder, spans, 0, 1);
final List<SpannableBuilder.Span> actual = builder.getSpans(0, builder.length());
assertEquals(2, actual.size());
for (int i = 0, length = spans.length; i < length; i++) {
assertEquals(spans[i], actual.get(i).what);
}
}
@Test
public void set_spans_null() {
// if `spans` argument is null, then nothing will be added
builder.append('0');
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
setSpans(builder, null, 0, builder.length());
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
}
@Test
public void spans_reversed() {
// resulting SpannableStringBuilder should have spans reversed
final Object[] spans = {
0,
1,
2
};
for (Object span : spans) {
builder.append(span.toString(), span);
}
final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder();
final Object[] actual = spannableStringBuilder.getSpans(0, builder.length(), Object.class);
for (int start = 0, length = spans.length, end = length - 1; start < length; start++, end--) {
assertEquals(spans[start], actual[end]);
}
}
@Test
public void append_spanned_normal() {
// #append is called with regular Spanned content -> spans should be added in reverse
final SpannableStringBuilder ssb = new SpannableStringBuilder();
for (int i = 0; i < 3; i++) {
ssb.append(String.valueOf(i));
ssb.setSpan(i, i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
builder.append(ssb);
assertEquals("012", builder.toString());
// this one would return normal order as spans are reversed here
// final List<SpannableBuilder.Span> spans = builder.getSpans(0, builder.length());
final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder();
final Object[] spans = spannableStringBuilder.getSpans(0, builder.length(), Object.class);
assertEquals(3, spans.length);
for (int i = 0, length = spans.length; i < length; i++) {
assertEquals(length - 1 - i, spans[i]);
}
}
@Test
public void append_spanned_reversed() {
// #append is called with reversed spanned content -> spans should be added as-are
final SpannableBuilder spannableBuilder = new SpannableBuilder();
for (int i = 0; i < 3; i++) {
spannableBuilder.append(String.valueOf(i), i);
}
assertTrue(builder.getSpans(0, builder.length()).isEmpty());
builder.append(spannableBuilder.spannableStringBuilder());
final SpannableStringBuilder spannableStringBuilder = builder.spannableStringBuilder();
final Object[] spans = spannableStringBuilder.getSpans(0, spannableStringBuilder.length(), Object.class);
assertEquals(3, spans.length);
for (int i = 0, length = spans.length; i < length; i++) {
// in the end order should be as we expect in order to properly render it
// (no matter if reversed is used or not)
assertEquals(length - 1 - i, spans[i]);
}
}
private static class Position {
@NonNull
static Position of(int length, int start, int end) {
return new Position(length, start, end);
}
final int length;
final int start;
final int end;
private Position(int length, int start, int end) {
this.length = length;
this.start = start;
this.end = end;
}
@Override
public String toString() {
return "Position{" +
"length=" + length +
", start=" + start +
", end=" + end +
'}';
}
}
}

View File

@ -0,0 +1,52 @@
package ru.noties.markwon.renderer;
import org.junit.Test;
import ru.noties.markwon.SpannableConfiguration;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.SyntaxHighlight;
import ru.noties.markwon.UrlProcessor;
import ru.noties.markwon.html.api.MarkwonHtmlParser;
import ru.noties.markwon.renderer.html2.MarkwonHtmlRenderer;
import ru.noties.markwon.spans.AsyncDrawable;
import ru.noties.markwon.spans.LinkSpan;
import ru.noties.markwon.spans.SpannableTheme;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
public class SpannableConfigurationTest {
@Test
public void testNewBuilder() {
final SpannableConfiguration configuration = SpannableConfiguration
.builder(null)
.theme(mock(SpannableTheme.class))
.asyncDrawableLoader(mock(AsyncDrawable.Loader.class))
.syntaxHighlight(mock(SyntaxHighlight.class))
.linkResolver(mock(LinkSpan.Resolver.class))
.urlProcessor(mock(UrlProcessor.class))
.imageSizeResolver(mock(ImageSizeResolver.class))
.factory(mock(SpannableFactory.class))
.softBreakAddsNewLine(true)
.htmlParser(mock(MarkwonHtmlParser.class))
.htmlRenderer(mock(MarkwonHtmlRenderer.class))
.htmlAllowNonClosedTags(true)
.build();
final SpannableConfiguration newConfiguration = configuration
.newBuilder(null)
.build();
assertEquals(configuration.theme(), newConfiguration.theme());
assertEquals(configuration.asyncDrawableLoader(), newConfiguration.asyncDrawableLoader());
assertEquals(configuration.syntaxHighlight(), newConfiguration.syntaxHighlight());
assertEquals(configuration.linkResolver(), newConfiguration.linkResolver());
assertEquals(configuration.urlProcessor(), newConfiguration.urlProcessor());
assertEquals(configuration.imageSizeResolver(), newConfiguration.imageSizeResolver());
assertEquals(configuration.factory(), newConfiguration.factory());
assertEquals(configuration.softBreakAddsNewLine(), newConfiguration.softBreakAddsNewLine());
assertEquals(configuration.htmlParser(), newConfiguration.htmlParser());
assertEquals(configuration.htmlRenderer(), newConfiguration.htmlRenderer());
assertEquals(configuration.htmlAllowNonClosedTags(), newConfiguration.htmlAllowNonClosedTags());
}
}

View File

@ -0,0 +1,111 @@
package ru.noties.markwon.renderer;
import android.content.Context;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import org.commonmark.node.FencedCodeBlock;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import ru.noties.markwon.SpannableBuilder;
import ru.noties.markwon.SpannableConfiguration;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.SyntaxHighlight;
import ru.noties.markwon.spans.SpannableTheme;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, sdk = {
Build.VERSION_CODES.JELLY_BEAN,
Build.VERSION_CODES.M,
Build.VERSION_CODES.O
})
public class SyntaxHighlightTest {
// codeSpan must be before actual highlight spans (true reverse of builder)
// if we go with path of reversing spans inside SpannableBuilder (which
// might extend SpannableStringBuilder like https://github.com/noties/Markwon/pull/71)
// then on M (23) codeSpan will always be _before_ actual highlight and thus
// no highlight will be present
// note that bad behaviour is present on M (emulator/device/robolectric)
// other SDKs are added to validate that they do not fail
@Test
public void test() {
class Highlight {
}
final Object codeSpan = new Object();
final SyntaxHighlight highlight = new SyntaxHighlight() {
@NonNull
@Override
public CharSequence highlight(@Nullable String info, @NonNull String code) {
final SpannableStringBuilder builder = new SpannableStringBuilder(code);
for (int i = 0, length = code.length(); i < length; i++) {
builder.setSpan(new Highlight(), i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
return builder;
}
};
final SpannableFactory factory = mock(SpannableFactory.class);
when(factory.code(any(SpannableTheme.class), anyBoolean())).thenReturn(codeSpan);
final SpannableConfiguration configuration = SpannableConfiguration.builder(mock(Context.class))
.syntaxHighlight(highlight)
.factory(factory)
.theme(mock(SpannableTheme.class))
.build();
final SpannableBuilder builder = new SpannableBuilder();
append(builder, "# Header 1\n", new Object());
append(builder, "## Header 2\n", new Object());
append(builder, "### Header 3\n", new Object());
final int start = builder.length();
final SpannableMarkdownVisitor visitor = new SpannableMarkdownVisitor(configuration, builder);
final FencedCodeBlock fencedCodeBlock = new FencedCodeBlock();
fencedCodeBlock.setLiteral("{code}");
visitor.visit(fencedCodeBlock);
final int end = builder.length();
append(builder, "### Footer 3\n", new Object());
append(builder, "## Footer 2\n", new Object());
append(builder, "# Footer 1\n", new Object());
final Object[] spans = builder.spannableStringBuilder().getSpans(start, end, Object.class);
// each character + code span
final int length = fencedCodeBlock.getLiteral().length() + 1;
assertEquals(length, spans.length);
assertEquals(codeSpan, spans[0]);
for (int i = 1; i < length; i++) {
assertTrue(spans[i] instanceof Highlight);
}
}
private static void append(@NonNull SpannableBuilder builder, @NonNull String text, @NonNull Object span) {
final int start = builder.length();
builder.append(text);
builder.setSpan(span, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}

View File

@ -0,0 +1,107 @@
package ru.noties.markwon.spans;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import ru.noties.markwon.renderer.ImageSize;
import ru.noties.markwon.renderer.ImageSizeResolver;
import ru.noties.markwon.renderer.ImageSizeResolverDef;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class AsyncDrawableTest {
private ImageSizeResolver imageSizeResolver;
@Before
public void before() {
imageSizeResolver = new ImageSizeResolverDef();
}
@Test
public void no_dimensions_await() {
// when drawable have no known dimensions yet, it will await for them
final AsyncDrawable drawable = new AsyncDrawable("",
mock(AsyncDrawable.Loader.class),
imageSizeResolver,
new ImageSize(new ImageSize.Dimension(100.F, "%"), null));
final Drawable result = new AbstractDrawable();
result.setBounds(0, 0, 0, 0);
assertFalse(drawable.hasResult());
drawable.setResult(result);
assertTrue(drawable.hasResult());
assertTrue(result.getBounds().isEmpty());
drawable.initWithKnownDimensions(100, 1);
assertEquals(
new Rect(0, 0, 100, 0),
result.getBounds()
);
}
@Test
public void previous_result_detached() {
// when result is present it will be detached (setCallback(null))
final AsyncDrawable drawable = new AsyncDrawable("",
mock(AsyncDrawable.Loader.class),
imageSizeResolver,
null);
drawable.setCallback2(mock(Drawable.Callback.class));
drawable.initWithKnownDimensions(100, 1);
final Drawable result1 = new AbstractDrawable();
final Drawable result2 = new AbstractDrawable();
drawable.setResult(result1);
assertNotNull(result1.getCallback());
drawable.setResult(result2);
assertNull(result1.getCallback());
assertNotNull(result2.getCallback());
}
private static class AbstractDrawable extends Drawable {
@Override
public void draw(@NonNull Canvas canvas) {
}
@Override
public void setAlpha(int alpha) {
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {
}
@Override
public int getOpacity() {
return 0;
}
}
}