diff --git a/README.md b/README.md index cf7ea17b..1bd0c545 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ allprojects { and then in your module `build.gradle`: ```groovy -implementation 'ru.noties:markwon:1.1.0-SNAPSHOT' +implementation 'ru.noties:markwon:1.1.1-SNAPSHOT' ``` Please note that `markwon-image-loader`, `markwon-syntax` and `markwon-view` are also present in `SNAPSHOT` repository and share the same version as main `markwon` artifact. diff --git a/build.gradle b/build.gradle index c0b701ed..21394ff7 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,14 @@ task wrapper(type: Wrapper) { distributionType 'all' } +if (hasProperty('local')) { + if (!hasProperty('LOCAL_MAVEN_URL')) { + throw new RuntimeException('Cannot publish to local maven as no such property exists: LOCAL_MAVEN_URL') + } + ext.RELEASE_REPOSITORY_URL = LOCAL_MAVEN_URL + ext.SNAPSHOT_REPOSITORY_URL = LOCAL_MAVEN_URL +} + ext { // Config diff --git a/gradle.properties b/gradle.properties index c9111b02..1fcd79d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ org.gradle.configureondemand=true android.enableBuildCache=true android.buildCacheDir=build/pre-dex-cache -VERSION_NAME=1.1.0 +VERSION_NAME=1.1.1-SNAPSHOT GROUP=ru.noties POM_DESCRIPTION=Markwon diff --git a/library-image-loader/build.gradle b/library-image-loader/build.gradle index 062f09d1..9d655ea3 100644 --- a/library-image-loader/build.gradle +++ b/library-image-loader/build.gradle @@ -30,9 +30,5 @@ afterEvaluate { } if (hasProperty('release')) { - if (hasProperty('local')) { - ext.RELEASE_REPOSITORY_URL = LOCAL_MAVEN_URL - ext.SNAPSHOT_REPOSITORY_URL = LOCAL_MAVEN_URL - } apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle' } diff --git a/library-syntax/build.gradle b/library-syntax/build.gradle index 70988485..2d6a2a67 100644 --- a/library-syntax/build.gradle +++ b/library-syntax/build.gradle @@ -24,9 +24,5 @@ afterEvaluate { } if (hasProperty('release')) { - if (hasProperty('local')) { - ext.RELEASE_REPOSITORY_URL = LOCAL_MAVEN_URL - ext.SNAPSHOT_REPOSITORY_URL = LOCAL_MAVEN_URL - } apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle' } diff --git a/library-view/build.gradle b/library-view/build.gradle index 6982037a..bc4a3c3b 100644 --- a/library-view/build.gradle +++ b/library-view/build.gradle @@ -15,7 +15,7 @@ android { dependencies { api project(':library') - compileOnly SUPPORT_APP_COMPAT + implementation SUPPORT_APP_COMPAT } afterEvaluate { @@ -23,9 +23,5 @@ afterEvaluate { } if (hasProperty('release')) { - if (hasProperty('local')) { - ext.RELEASE_REPOSITORY_URL = LOCAL_MAVEN_URL - ext.SNAPSHOT_REPOSITORY_URL = LOCAL_MAVEN_URL - } apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle' } diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java index 19ab09b0..3f2130af 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonView.java @@ -2,8 +2,12 @@ package ru.noties.markwon.view; import android.annotation.SuppressLint; import android.content.Context; +import android.os.Build; +import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.annotation.StyleRes; import android.util.AttributeSet; import android.widget.TextView; @@ -14,19 +18,36 @@ public class MarkwonView extends TextView implements IMarkwonView { private MarkwonViewHelper helper; - public MarkwonView(Context context) { + public MarkwonView(@NonNull Context context) { super(context); - init(context, null); + init(context, null, R.attr.markwonViewStyle, 0); } - public MarkwonView(Context context, AttributeSet attrs) { + public MarkwonView(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); - init(context, attrs); + init(context, attrs, R.attr.markwonViewStyle, 0); } - private void init(Context context, AttributeSet attributeSet) { + public MarkwonView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr, 0); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public MarkwonView(Context context, + @Nullable AttributeSet attrs, + int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + private void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr, + @StyleRes int defStyleRes) { helper = MarkwonViewHelper.create(this); - helper.init(context, attributeSet); + helper.init(context, attributeSet, defStyleAttr, defStyleRes); } @Override diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java index da5c1934..9619b995 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewCompat.java @@ -1,6 +1,7 @@ package ru.noties.markwon.view; import android.content.Context; +import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.AppCompatTextView; @@ -12,19 +13,28 @@ public class MarkwonViewCompat extends AppCompatTextView implements IMarkwonView private MarkwonViewHelper helper; - public MarkwonViewCompat(Context context) { + public MarkwonViewCompat(@NonNull Context context) { super(context); - init(context, null); + init(context, null, R.attr.markwonViewStyle); } - public MarkwonViewCompat(Context context, AttributeSet attrs) { + public MarkwonViewCompat(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); - init(context, attrs); + init(context, attrs, R.attr.markwonViewStyle); } - private void init(Context context, AttributeSet attributeSet) { + public MarkwonViewCompat(@NonNull Context context, + @Nullable AttributeSet attrs, + @AttrRes int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr); + } + + private void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr) { helper = MarkwonViewHelper.create(this); - helper.init(context, attributeSet); + helper.init(context, attributeSet, defStyleAttr, 0); } @Override diff --git a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java index c8c813f6..4d1eeecf 100644 --- a/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java +++ b/library-view/src/main/java/ru/noties/markwon/view/MarkwonViewHelper.java @@ -1,15 +1,40 @@ package ru.noties.markwon.view; import android.content.Context; +import android.content.res.Resources; import android.content.res.TypedArray; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.os.Build; +import android.support.annotation.AttrRes; +import android.support.annotation.FontRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.StyleRes; +import android.support.annotation.StyleableRes; +import android.support.v4.content.res.ResourcesCompat; +import android.text.TextPaint; import android.text.TextUtils; +import android.text.style.MetricAffectingSpan; +import android.text.style.TextAppearanceSpan; import android.util.AttributeSet; +import android.util.SparseIntArray; import android.widget.TextView; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + import ru.noties.markwon.Markwon; import ru.noties.markwon.SpannableConfiguration; +import ru.noties.markwon.SpannableFactory; +import ru.noties.markwon.SpannableFactoryDef; +import ru.noties.markwon.renderer.ImageSize; +import ru.noties.markwon.renderer.ImageSizeResolver; +import ru.noties.markwon.spans.AsyncDrawable; +import ru.noties.markwon.spans.LinkSpan; +import ru.noties.markwon.spans.SpannableTheme; +import ru.noties.markwon.spans.TableRowSpan; public class MarkwonViewHelper implements IMarkwonView { @@ -24,34 +49,48 @@ public class MarkwonViewHelper implements IMarkwonView { private SpannableConfiguration configuration; private String markdown; + @NonNull + private final SparseIntArray styles; + private MarkwonViewHelper(@NonNull TextView textView) { this.textView = textView; + this.styles = new SparseIntArray(); } - public void init(Context context, AttributeSet attributeSet) { + public void init(@NonNull Context context, + @Nullable AttributeSet attributeSet, + @AttrRes int defStyleAttr, + @StyleRes int defStyleRes) { + final TypedArray array = context.obtainStyledAttributes( + attributeSet, + R.styleable.MarkwonView, + defStyleAttr, + defStyleRes); + try { + final int count = array.getIndexCount(); + for (int idx = 0; idx < count; idx++) { + @StyleableRes final int relativeIndex = array.getIndex(idx); - if (attributeSet != null) { - final TypedArray array = context.obtainStyledAttributes(attributeSet, R.styleable.MarkwonView); - try { - - final String configurationProvider = array.getString(R.styleable.MarkwonView_mv_configurationProvider); - final ConfigurationProvider provider; - if (!TextUtils.isEmpty(configurationProvider)) { - provider = MarkwonViewHelper.obtainProvider(configurationProvider); - } else { - provider = null; - } - if (provider != null) { - setConfigurationProvider(provider); - } - - final String markdown = array.getString(R.styleable.MarkwonView_mv_markdown); - if (!TextUtils.isEmpty(markdown)) { - setMarkdown(markdown); - } - } finally { - array.recycle(); + styles.put(relativeIndex, array.getResourceId(relativeIndex, 0)); } + + final String configurationProvider = array.getString(R.styleable.MarkwonView_mv_configurationProvider); + final ConfigurationProvider provider; + if (!TextUtils.isEmpty(configurationProvider)) { + provider = MarkwonViewHelper.obtainProvider(configurationProvider); + } else { + provider = null; + } + if (provider != null) { + setConfigurationProvider(provider); + } + + final String markdown = array.getString(R.styleable.MarkwonView_mv_markdown); + if (!TextUtils.isEmpty(markdown)) { + setMarkdown(markdown); + } + } finally { + array.recycle(); } } @@ -78,7 +117,10 @@ public class MarkwonViewHelper implements IMarkwonView { if (provider != null) { this.configuration = provider.provide(textView.getContext()); } else { - this.configuration = SpannableConfiguration.create(textView.getContext()); + this.configuration = SpannableConfiguration + .builder(textView.getContext()) + .factory(new StyleableSpanFactory(textView.getContext(), styles)) + .build(); } } configuration = this.configuration; @@ -93,7 +135,7 @@ public class MarkwonViewHelper implements IMarkwonView { } @Nullable - public static IMarkwonView.ConfigurationProvider obtainProvider(@NonNull String className) { + private static IMarkwonView.ConfigurationProvider obtainProvider(@NonNull String className) { try { final Class cl = Class.forName(className); return (IMarkwonView.ConfigurationProvider) cl.newInstance(); @@ -102,4 +144,318 @@ public class MarkwonViewHelper implements IMarkwonView { return null; } } + + /** + * A SpannableFactory that creates spans based on style attributes of MarkwonView + */ + private static final class StyleableSpanFactory implements SpannableFactory { + @NonNull + private static final int[] TEXT_APPEARANCE_ATTR = new int[]{android.R.attr.textAppearance}; + + private static final int NO_VALUE = -1; + + @NonNull + private static final SparseIntArray HEADING_STYLE_MAP = new SparseIntArray(); + + static { + // Unlike library attributes, the index of styleables are stable across builds making + // them safe to declare statically. + HEADING_STYLE_MAP.put(1, R.styleable.MarkwonView_mv_H1Style); + HEADING_STYLE_MAP.put(2, R.styleable.MarkwonView_mv_H2Style); + HEADING_STYLE_MAP.put(3, R.styleable.MarkwonView_mv_H3Style); + HEADING_STYLE_MAP.put(4, R.styleable.MarkwonView_mv_H4Style); + HEADING_STYLE_MAP.put(5, R.styleable.MarkwonView_mv_H5Style); + HEADING_STYLE_MAP.put(6, R.styleable.MarkwonView_mv_H6Style); + } + + @NonNull + private final Context context; + + @NonNull + private final SparseIntArray styles; + + @NonNull + private final SpannableFactory defaultFactory; + + StyleableSpanFactory(@NonNull Context context, @NonNull SparseIntArray styles) { + this.context = context; + this.styles = styles; + this.defaultFactory = SpannableFactoryDef.create(); + + } + + @Nullable + @Override + public Object strongEmphasis() { + final int style = styles.get(R.styleable.MarkwonView_mv_StrongEmphasisStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } else { + return defaultFactory.strongEmphasis(); + } + } + + @Nullable + @Override + public Object emphasis() { + final int style = styles.get(R.styleable.MarkwonView_mv_EmphasisStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } else { + return defaultFactory.emphasis(); + } + } + + @Nullable + @Override + public Object blockQuote(@NonNull SpannableTheme theme) { + final int style = styles.get(R.styleable.MarkwonView_mv_BlockQuoteStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.blockQuote(theme))); + } + + return defaultFactory.blockQuote(theme); + } + + @Nullable + @Override + public Object code(@NonNull SpannableTheme theme, boolean multiline) { + final int styleAttr = multiline ? R.styleable.MarkwonView_mv_MultilineCodeSpanStyle : + R.styleable.MarkwonView_mv_CodeSpanStyle; + final int style = styles.get(styleAttr, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.code(theme, multiline))); + } + + return defaultFactory.code(theme, multiline); + } + + @Nullable + @Override + public Object orderedListItem(@NonNull SpannableTheme theme, int startNumber) { + final int style = styles.get(R.styleable.MarkwonView_mv_OrderedListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.orderedListItem(theme, startNumber))); + } + + return defaultFactory.orderedListItem(theme, startNumber); + } + + @Nullable + @Override + public Object bulletListItem(@NonNull SpannableTheme theme, int level) { + final int style = styles.get(R.styleable.MarkwonView_mv_BulletListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory.bulletListItem(theme, level))); + } + return defaultFactory.bulletListItem(theme, level); + } + + @Nullable + @Override + public Object thematicBreak(@NonNull SpannableTheme theme) { + return defaultFactory.thematicBreak(theme); + } + + @Nullable + @Override + public Object heading(@NonNull SpannableTheme theme, int level) { + final int style = styles.get(HEADING_STYLE_MAP.get(level), NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } + return defaultFactory.heading(theme, level); + } + + @Nullable + @Override + public Object strikethrough() { + return defaultFactory.strikethrough(); + } + + @Nullable + @Override + public Object taskListItem(@NonNull SpannableTheme theme, int blockIndent, boolean isDone) { + final int style = styles.get(R.styleable.MarkwonView_mv_TaskListItemStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory + .taskListItem(theme, blockIndent, isDone))); + } + + return defaultFactory.taskListItem(theme, blockIndent, isDone); + } + + @Nullable + @Override + public Object tableRow(@NonNull SpannableTheme theme, + @NonNull List cells, + boolean isHeader, + boolean isOdd) { + final int style = styles.get(R.styleable.MarkwonView_mv_TableRowStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style, + createSpanCollection(defaultFactory + .tableRow(theme, cells, isHeader, isOdd))); + } + + return defaultFactory.tableRow(theme, cells, isHeader, isOdd); + } + + @Nullable + @Override + public Object image(@NonNull SpannableTheme theme, + @NonNull String destination, + @NonNull AsyncDrawable.Loader loader, + @NonNull ImageSizeResolver imageSizeResolver, + @Nullable ImageSize imageSize, + boolean replacementTextIsLink) { + return defaultFactory.image( + theme, + destination, + loader, + imageSizeResolver, + imageSize, + replacementTextIsLink); + } + + @Nullable + @Override + public Object link(@NonNull SpannableTheme theme, + @NonNull String destination, + @NonNull LinkSpan.Resolver resolver) { + final int style = styles.get(R.styleable.MarkwonView_mv_LinkStyle, NO_VALUE); + if (style != NO_VALUE) { + return createFromStyle(style); + } + + return defaultFactory.link(theme, destination, resolver); + } + + @Nullable + @Override + public Object superScript(@NonNull SpannableTheme theme) { + return defaultFactory.superScript(theme); + } + + @Nullable + @Override + public Object subScript(@NonNull SpannableTheme theme) { + return defaultFactory.subScript(theme); + } + + @Nullable + @Override + public Object underline() { + return defaultFactory.underline(); + } + + + @NonNull + private Object[] createFromStyle(@StyleRes int styleResource) { + return createFromStyle(styleResource, new ArrayDeque<>()); + } + + @NonNull + private Object[] createFromStyle(@StyleRes int styleResource, + @NonNull Deque spans) { + TypedArray a = context.obtainStyledAttributes(styleResource, TEXT_APPEARANCE_ATTR); + final int ap = a.getResourceId(0, NO_VALUE); + a.recycle(); + if (ap != -1) { + spans.addFirst(new TextAppearanceSpan(context, ap)); + handleCustomFont(ap, spans); + } + + spans.addFirst(new TextAppearanceSpan(context, styleResource)); + handleCustomFont(styleResource, spans); + + return spans.toArray(); + } + + private void handleCustomFont(@StyleRes int style, @NonNull Deque spans) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + // In SDK 27, custom fonts are handled in TextAppearanceSpan so no need to re-invent + // the wheel. + + return; + } + + final TypedArray a = context.obtainStyledAttributes( + style, R.styleable.MV_CustomFonts); + + if (a.hasValue(R.styleable.MV_CustomFonts_android_fontFamily) || + a.hasValue(R.styleable.MV_CustomFonts_fontFamily)) { + @StyleableRes int resolvedFontFamily = + a.hasValue(R.styleable.MV_CustomFonts_android_fontFamily) + ? R.styleable.MV_CustomFonts_android_fontFamily + : R.styleable.MV_CustomFonts_fontFamily; + try { + @FontRes int fontResId = a.getResourceId(resolvedFontFamily, NO_VALUE); + if (fontResId != NO_VALUE) { + final Typeface typeface = ResourcesCompat.getFont(context, fontResId); + if (typeface != null) { + spans.addFirst(new CustomTypefaceSpan(typeface)); + } + } + } catch (UnsupportedOperationException | Resources.NotFoundException e) { + // Expected if it is not a font resource. + } + } + + a.recycle(); + } + + private static Deque createSpanCollection(@Nullable Object defaultSpan) { + final Deque spanCollection = new ArrayDeque<>(); + if (defaultSpan instanceof Object[]) { + for (final Object span : (Object[]) defaultSpan) { + spanCollection.addLast(span); + } + } else if (defaultSpan != null) { + spanCollection.addLast(defaultSpan); + } + + return spanCollection; + } + + // taken from https://stackoverflow.com/a/17961854 + private static class CustomTypefaceSpan extends MetricAffectingSpan { + private final Typeface typeface; + + CustomTypefaceSpan(final Typeface typeface) { + this.typeface = typeface; + } + + @Override + public void updateDrawState(final TextPaint drawState) { + apply(drawState); + } + + @Override + public void updateMeasureState(final TextPaint paint) { + apply(paint); + } + + private void apply(final Paint paint) { + final Typeface oldTypeface = paint.getTypeface(); + final int oldStyle = oldTypeface != null ? oldTypeface.getStyle() : 0; + final int fakeStyle = oldStyle & ~typeface.getStyle(); + + if ((fakeStyle & Typeface.BOLD) != 0) { + paint.setFakeBoldText(true); + } + + if ((fakeStyle & Typeface.ITALIC) != 0) { + paint.setTextSkewX(-0.25f); + } + + paint.setTypeface(typeface); + } + } + } } diff --git a/library-view/src/main/res/values/attrs.xml b/library-view/src/main/res/values/attrs.xml index 33a532f3..6805313b 100644 --- a/library-view/src/main/res/values/attrs.xml +++ b/library-view/src/main/res/values/attrs.xml @@ -1,9 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/library/build.gradle b/library/build.gradle index 795f7407..a9bc3ee2 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -25,9 +25,5 @@ afterEvaluate { } if (hasProperty('release')) { - if (hasProperty('local')) { - ext.RELEASE_REPOSITORY_URL = LOCAL_MAVEN_URL - ext.SNAPSHOT_REPOSITORY_URL = LOCAL_MAVEN_URL - } apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle' } diff --git a/library/src/main/java/ru/noties/markwon/ReverseSpannableStringBuilder.java b/library/src/main/java/ru/noties/markwon/ReverseSpannableStringBuilder.java new file mode 100644 index 00000000..1bcf0b7a --- /dev/null +++ b/library/src/main/java/ru/noties/markwon/ReverseSpannableStringBuilder.java @@ -0,0 +1,42 @@ +package ru.noties.markwon; + +import android.text.SpannableStringBuilder; + +/** + * Copied as is from @see Uncodin/bypass + */ +public class ReverseSpannableStringBuilder extends SpannableStringBuilder { + + public ReverseSpannableStringBuilder() { + super(); + } + + public ReverseSpannableStringBuilder(CharSequence text, int start, int end) { + super(text, start, end); + } + + @Override + public T[] getSpans(int queryStart, int queryEnd, Class kind) { + T[] ret = super.getSpans(queryStart, queryEnd, kind); + reverse(ret); + return ret; + } + + private static void reverse(Object[] arr) { + if (arr == null) { + return; + } + + int i = 0; + int j = arr.length - 1; + Object tmp; + while (j > i) { + tmp = arr[j]; + arr[j] = arr[i]; + arr[i] = tmp; + j--; + i++; + } + } +} diff --git a/library/src/main/java/ru/noties/markwon/SpannableBuilder.java b/library/src/main/java/ru/noties/markwon/SpannableBuilder.java deleted file mode 100644 index 5cc2420f..00000000 --- a/library/src/main/java/ru/noties/markwon/SpannableBuilder.java +++ /dev/null @@ -1,241 +0,0 @@ -package ru.noties.markwon; - -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.text.SpannableStringBuilder; -import android.text.Spanned; - -import java.util.ArrayDeque; -import java.util.Deque; -import java.util.Iterator; - -/** - * This class is used to _revert_ order of applied spans. Original SpannableStringBuilder - * is using an array to store all the information about spans. So, a span that is added first - * will be drawn first, which leads to subtle bugs (spans receive wrong `x` values when - * requested to draw itself) - * - * @since 1.0.1 - */ -@SuppressWarnings({"WeakerAccess", "unused"}) -public class SpannableBuilder { - - // do not implement CharSequence (or any of Spanned interfaces) - - // we will be using SpannableStringBuilder anyway as a backing store - // as it has tight connection with system (implements some hidden methods, etc) - private final SpannableStringBuilder builder; - - // actually we might be just using ArrayList - private final Deque spans = new ArrayDeque<>(8); - - public SpannableBuilder() { - this(""); - } - - public SpannableBuilder(@NonNull CharSequence cs) { - this.builder = new SpannableStringBuilderImpl(cs.toString()); - copySpans(0, cs); - } - - /** - * Additional method that takes a String, which is proven to NOT contain any spans - * - * @param text String to append - * @return this instance - */ - @NonNull - public SpannableBuilder append(@NonNull String text) { - builder.append(text); - return this; - } - - @NonNull - public SpannableBuilder append(char c) { - builder.append(c); - return this; - } - - @NonNull - public SpannableBuilder append(@NonNull CharSequence cs) { - - copySpans(length(), cs); - - builder.append(cs.toString()); - - return this; - } - - @NonNull - public SpannableBuilder append(@NonNull CharSequence cs, @NonNull Object span) { - final int length = length(); - append(cs); - setSpan(span, length); - return this; - } - - @NonNull - public SpannableBuilder append(@NonNull CharSequence cs, @NonNull Object span, int flags) { - final int length = length(); - append(cs); - setSpan(span, length, length(), flags); - return this; - } - - @NonNull - public SpannableBuilder setSpan(@NonNull Object span, int start) { - return setSpan(span, start, length()); - } - - @NonNull - public SpannableBuilder setSpan(@NonNull Object span, int start, int end) { - return setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - } - - @NonNull - public SpannableBuilder setSpan(@NonNull Object span, int start, int end, int flags) { - spans.push(new Span(span, start, end, flags)); - return this; - } - - public int length() { - return builder.length(); - } - - public char charAt(int index) { - return builder.charAt(index); - } - - public char lastChar() { - return builder.charAt(length() - 1); - } - - @NonNull - public CharSequence removeFromEnd(int start) { - - // this method is not intended to be used by clients - // it's a workaround to support tables - - 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 - final SpannableStringBuilderImpl impl = new SpannableStringBuilderImpl(builder.subSequence(start, end)); - - final Iterator iterator = spans.iterator(); - - Span span; - - while (iterator.hasNext() && ((span = iterator.next())) != null) { - if (span.start >= start && span.end <= end) { - impl.setSpan(span.what, span.start - start, span.end - start, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - iterator.remove(); - } - } - - builder.replace(start, end, ""); - - return impl; - } - - @Override - @NonNull - public String toString() { - return builder.toString(); - } - - @NonNull - public CharSequence text() { - - // okay, in order to not allow external modification and keep our spans order - // we should not return our builder - // - // plus, if this method was called -> all spans would be applied, which potentially - // breaks the order that we intend to use - // so, we will defensively copy builder - - // 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); - - for (Span span : spans) { - impl.setSpan(span.what, span.start, span.end, span.flags); - } - - // now, let's remove trailing newLines (so small amounts of text are displayed correctly) - // @since 1.0.2 - - final int length = impl.length(); - if (length > 0) { - int amount = 0; - for (int i = length - 1; i >= 0; i--) { - if (Character.isWhitespace(impl.charAt(i))) { - amount += 1; - } else { - break; - } - } - if (amount > 0) { - impl.replace(length - amount, length, ""); - } - } - - return impl; - } - - private void copySpans(final int index, @Nullable CharSequence cs) { - - // we must identify already reversed Spanned... - // and (!) iterate backwards when adding (to preserve order) - - if (cs instanceof Spanned) { - - final Spanned spanned = (Spanned) cs; - final boolean reverse = spanned instanceof SpannedReversed; - - final Object[] spans = spanned.getSpans(0, spanned.length(), Object.class); - final int length = spans != null - ? spans.length - : 0; - - if (length > 0) { - if (reverse) { - Object o; - for (int i = length - 1; i >= 0; i--) { - o = spans[i]; - setSpan( - o, - index + spanned.getSpanStart(o), - index + spanned.getSpanEnd(o), - spanned.getSpanFlags(o) - ); - } - } else { - Object o; - for (int i = 0; i < length; i++) { - o = spans[i]; - setSpan( - o, - index + spanned.getSpanStart(o), - index + spanned.getSpanEnd(o), - spanned.getSpanFlags(o) - ); - } - } - } - } - } - - static class Span { - - final Object what; - int start; - int end; - final int flags; - - Span(@NonNull Object what, int start, int end, int flags) { - this.what = what; - this.start = start; - this.end = end; - this.flags = flags; - } - } -} diff --git a/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java b/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java index dda590f5..013f9989 100644 --- a/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java +++ b/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java @@ -32,6 +32,7 @@ public class SpannableConfiguration { private final SpannableHtmlParser htmlParser; private final ImageSizeResolver imageSizeResolver; private final SpannableFactory factory; // @since 1.1.0 + private final boolean softBreakAddsNewLine; // @since 1.1.1 private SpannableConfiguration(@NonNull Builder builder) { this.theme = builder.theme; @@ -42,6 +43,7 @@ public class SpannableConfiguration { this.htmlParser = builder.htmlParser; this.imageSizeResolver = builder.imageSizeResolver; this.factory = builder.factory; + this.softBreakAddsNewLine = builder.softBreakAddsNewLine; } @NonNull @@ -84,6 +86,15 @@ public class SpannableConfiguration { return factory; } + /** + * @return a flag indicating if soft break should be treated as a hard + * break and thus adding a new line instead of adding a white space + * @since 1.1.1 + */ + public boolean softBreakAddsNewLine() { + return softBreakAddsNewLine; + } + @SuppressWarnings("unused") public static class Builder { @@ -95,7 +106,8 @@ public class SpannableConfiguration { private UrlProcessor urlProcessor; private SpannableHtmlParser htmlParser; private ImageSizeResolver imageSizeResolver; - private SpannableFactory factory; + private SpannableFactory factory; // @since 1.1.0 + private boolean softBreakAddsNewLine; // @since 1.1.1 Builder(@NonNull Context context) { this.context = context; @@ -155,6 +167,19 @@ public class SpannableConfiguration { return this; } + /** + * @param softBreakAddsNewLine a flag indicating if soft break should be treated as a hard + * break and thus adding a new line instead of adding a white space + * @return self + * @see spec + * @since 1.1.1 + */ + @NonNull + public Builder softBreakAddsNewLine(boolean softBreakAddsNewLine) { + this.softBreakAddsNewLine = softBreakAddsNewLine; + return this; + } + @NonNull public SpannableConfiguration build() { diff --git a/library/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java b/library/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java deleted file mode 100644 index 7b29440b..00000000 --- a/library/src/main/java/ru/noties/markwon/SpannableStringBuilderImpl.java +++ /dev/null @@ -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); - } -} diff --git a/library/src/main/java/ru/noties/markwon/SpannedReversed.java b/library/src/main/java/ru/noties/markwon/SpannedReversed.java deleted file mode 100644 index 3fd7f566..00000000 --- a/library/src/main/java/ru/noties/markwon/SpannedReversed.java +++ /dev/null @@ -1,9 +0,0 @@ -package ru.noties.markwon; - -import android.text.Spanned; - -/** - * @since 1.0.1 - */ -interface SpannedReversed extends Spanned { -} diff --git a/library/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java b/library/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java index 82745e50..0b5886d6 100644 --- a/library/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java +++ b/library/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java @@ -2,6 +2,7 @@ package ru.noties.markwon.renderer; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; @@ -39,7 +40,7 @@ import java.util.ArrayList; import java.util.Deque; import java.util.List; -import ru.noties.markwon.SpannableBuilder; +import ru.noties.markwon.ReverseSpannableStringBuilder; import ru.noties.markwon.SpannableConfiguration; import ru.noties.markwon.SpannableFactory; import ru.noties.markwon.renderer.html.SpannableHtmlParser; @@ -52,7 +53,7 @@ import ru.noties.markwon.tasklist.TaskListItem; public class SpannableMarkdownVisitor extends AbstractVisitor { private final SpannableConfiguration configuration; - private final SpannableBuilder builder; + private final SpannableStringBuilder builder; private final Deque htmlInlineItems; private final SpannableTheme theme; @@ -67,7 +68,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { public SpannableMarkdownVisitor( @NonNull SpannableConfiguration configuration, - @NonNull SpannableBuilder builder + @NonNull SpannableStringBuilder builder ) { this.configuration = configuration; this.builder = builder; @@ -256,8 +257,12 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { @Override public void visit(SoftLineBreak softLineBreak) { - // at first here was a new line, but here should be a space char - builder.append(' '); + // @since 1.1.1 there is an option to treat soft break as a hard break (thus adding new line) + if (configuration.softBreakAddsNewLine()) { + newLine(); + } else { + builder.append(' '); + } } @Override @@ -359,10 +364,9 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { if (pendingTableRow == null) { pendingTableRow = new ArrayList<>(2); } - pendingTableRow.add(new TableRowSpan.Cell( tableCellAlignment(cell.getAlignment()), - builder.removeFromEnd(length) + removeFromEnd(length) )); tableRowIsHeader = cell.isHeader(); @@ -374,6 +378,22 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { return handled; } + @NonNull + private CharSequence removeFromEnd(int start) { + + // this method is not intended to be used by clients + // it's a workaround to support tables + + final int end = builder.length(); + + // as we do not expose builder and do no apply spans to it, we are safe to NOT to convert to String + final SpannableStringBuilder impl = new ReverseSpannableStringBuilder(builder, start, end); + builder.delete(start, end); + + return impl; + } + + @Override public void visit(Paragraph paragraph) { @@ -497,7 +517,7 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { private void newLine() { if (builder.length() > 0 - && '\n' != builder.lastChar()) { + && '\n' != builder.charAt(builder.length() - 1)) { builder.append('\n'); } } diff --git a/library/src/main/java/ru/noties/markwon/renderer/SpannableRenderer.java b/library/src/main/java/ru/noties/markwon/renderer/SpannableRenderer.java index 32d620dd..15acf2a3 100644 --- a/library/src/main/java/ru/noties/markwon/renderer/SpannableRenderer.java +++ b/library/src/main/java/ru/noties/markwon/renderer/SpannableRenderer.java @@ -1,18 +1,19 @@ package ru.noties.markwon.renderer; import android.support.annotation.NonNull; +import android.text.SpannableStringBuilder; import org.commonmark.node.Node; -import ru.noties.markwon.SpannableBuilder; +import ru.noties.markwon.ReverseSpannableStringBuilder; import ru.noties.markwon.SpannableConfiguration; public class SpannableRenderer { @NonNull public CharSequence render(@NonNull SpannableConfiguration configuration, @NonNull Node node) { - final SpannableBuilder builder = new SpannableBuilder(); + final SpannableStringBuilder builder = new ReverseSpannableStringBuilder(); node.accept(new SpannableMarkdownVisitor(configuration, builder)); - return builder.text(); + return builder; } } diff --git a/library/src/main/java/ru/noties/markwon/spans/CanvasUtils.java b/library/src/main/java/ru/noties/markwon/spans/CanvasUtils.java index b55783b8..0851e855 100644 --- a/library/src/main/java/ru/noties/markwon/spans/CanvasUtils.java +++ b/library/src/main/java/ru/noties/markwon/spans/CanvasUtils.java @@ -6,7 +6,8 @@ import android.support.annotation.NonNull; abstract class CanvasUtils { static float textCenterY(int top, int bottom, @NonNull Paint paint) { - return (int) (bottom - ((bottom - top) / 2) - ((paint.descent() + paint.ascent()) / 2.F + .5F)); + // @since 1.1.1 it's `top +` and not `bottom -` + return (int) (top + ((bottom - top) / 2) - ((paint.descent() + paint.ascent()) / 2.F + .5F)); } private CanvasUtils() { diff --git a/library/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java b/library/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java index 194f7676..16e9895f 100644 --- a/library/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java +++ b/library/src/main/java/ru/noties/markwon/spans/OrderedListItemSpan.java @@ -59,8 +59,7 @@ public class OrderedListItemSpan implements LeadingMarginSpan { left = x + (width * dir) + (width - numberWidth); } - final float numberY = CanvasUtils.textCenterY(top, bottom, p); - - c.drawText(number, left, numberY, p); + // @since 1.1.1 we are using `baseline` argument to position text + c.drawText(number, left, baseline, p); } } diff --git a/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/IconVisitor.java b/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/IconVisitor.java index d373ff75..e6c6b8c4 100644 --- a/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/IconVisitor.java +++ b/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/IconVisitor.java @@ -1,25 +1,25 @@ package ru.noties.markwon.sample.extension; import android.support.annotation.NonNull; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; -import android.widget.TextView; import org.commonmark.node.CustomNode; -import ru.noties.markwon.SpannableBuilder; import ru.noties.markwon.SpannableConfiguration; import ru.noties.markwon.renderer.SpannableMarkdownVisitor; @SuppressWarnings("WeakerAccess") public class IconVisitor extends SpannableMarkdownVisitor { - private final SpannableBuilder builder; + private final SpannableStringBuilder builder; private final IconSpanProvider iconSpanProvider; public IconVisitor( @NonNull SpannableConfiguration configuration, - @NonNull SpannableBuilder builder, + @NonNull SpannableStringBuilder builder, @NonNull IconSpanProvider iconSpanProvider ) { super(configuration, builder); @@ -51,7 +51,7 @@ public class IconVisitor extends SpannableMarkdownVisitor { final int length = builder.length(); builder.append(name); - builder.setSpan(iconSpanProvider.provide(name, color, size), length); + builder.setSpan(iconSpanProvider.provide(name, color, size), length, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); builder.append(' '); return true; diff --git a/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/MainActivity.java b/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/MainActivity.java index 19a69704..bf705fb3 100644 --- a/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/MainActivity.java +++ b/sample-custom-extension/src/main/java/ru/noties/markwon/sample/extension/MainActivity.java @@ -3,6 +3,7 @@ package ru.noties.markwon.sample.extension; import android.app.Activity; import android.graphics.Typeface; import android.os.Bundle; +import android.text.SpannableStringBuilder; import android.widget.TextView; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; @@ -12,7 +13,7 @@ import org.commonmark.parser.Parser; import java.util.Arrays; -import ru.noties.markwon.SpannableBuilder; +import ru.noties.markwon.ReverseSpannableStringBuilder; import ru.noties.markwon.SpannableConfiguration; import ru.noties.markwon.spans.SpannableTheme; import ru.noties.markwon.tasklist.TaskListExtension; @@ -45,7 +46,7 @@ public class MainActivity extends Activity { final Node node = parser.parse(markdown); - final SpannableBuilder builder = new SpannableBuilder(); + final SpannableStringBuilder builder = new ReverseSpannableStringBuilder(); // please note that here I am passing `0` as fallback it means that if markdown references // unknown icon, it will try to load fallback one and will fail with ResourceNotFound. It's @@ -70,6 +71,6 @@ public class MainActivity extends Activity { node.accept(visitor); // apply - textView.setText(builder.text()); + textView.setText(builder); } }