diff --git a/README.md b/README.md index 1e33a9f4..5fe29ccf 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,10 @@ compile 'ru.noties:markwon-view:1.0.0' // optional Taken with default configuration (except for image loading): - - + + + + By default configuration uses TextView textColor for styling, so changing textColor changes style diff --git a/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java b/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java index 21b0ae7a..f276dfe2 100644 --- a/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java +++ b/app/src/main/java/ru/noties/markwon/MarkdownRenderer.java @@ -3,6 +3,7 @@ package ru.noties.markwon; import android.content.Context; import android.net.Uri; import android.os.Handler; +import android.os.SystemClock; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -11,6 +12,7 @@ import java.util.concurrent.Future; import javax.inject.Inject; +import ru.noties.debug.Debug; import ru.noties.markwon.spans.AsyncDrawable; @ActivityScope @@ -57,8 +59,14 @@ public class MarkdownRenderer { .urlProcessor(urlProcessor) .build(); + final long start = SystemClock.uptimeMillis(); + final CharSequence text = Markwon.markdown(configuration, markdown); + final long end = SystemClock.uptimeMillis(); + + Debug.i("markdown rendered: %d ms", end - start); + if (!isCancelled()) { handler.post(new Runnable() { @Override diff --git a/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java b/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java index aa1af671..3b9332bd 100644 --- a/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java +++ b/library/src/main/java/ru/noties/markwon/SpannableConfiguration.java @@ -3,6 +3,8 @@ package ru.noties.markwon; import android.content.Context; import android.support.annotation.NonNull; +import ru.noties.markwon.renderer.html.ImageSizeResolver; +import ru.noties.markwon.renderer.html.ImageSizeResolverDef; import ru.noties.markwon.renderer.html.SpannableHtmlParser; import ru.noties.markwon.spans.AsyncDrawable; import ru.noties.markwon.spans.LinkSpan; @@ -12,10 +14,12 @@ import ru.noties.markwon.spans.SpannableTheme; public class SpannableConfiguration { // creates default configuration + @NonNull public static SpannableConfiguration create(@NonNull Context context) { return new Builder(context).build(); } + @NonNull public static Builder builder(@NonNull Context context) { return new Builder(context); } @@ -27,7 +31,7 @@ public class SpannableConfiguration { private final UrlProcessor urlProcessor; private final SpannableHtmlParser htmlParser; - private SpannableConfiguration(Builder builder) { + private SpannableConfiguration(@NonNull Builder builder) { this.theme = builder.theme; this.asyncDrawableLoader = builder.asyncDrawableLoader; this.syntaxHighlight = builder.syntaxHighlight; @@ -36,26 +40,32 @@ public class SpannableConfiguration { this.htmlParser = builder.htmlParser; } + @NonNull public SpannableTheme theme() { return theme; } + @NonNull public AsyncDrawable.Loader asyncDrawableLoader() { return asyncDrawableLoader; } + @NonNull public SyntaxHighlight syntaxHighlight() { return syntaxHighlight; } + @NonNull public LinkSpan.Resolver linkResolver() { return linkResolver; } + @NonNull public UrlProcessor urlProcessor() { return urlProcessor; } + @NonNull public SpannableHtmlParser htmlParser() { return htmlParser; } @@ -70,60 +80,89 @@ public class SpannableConfiguration { private LinkSpan.Resolver linkResolver; private UrlProcessor urlProcessor; private SpannableHtmlParser htmlParser; + private ImageSizeResolver imageSizeResolver; - Builder(Context context) { + Builder(@NonNull Context context) { this.context = context; } - public Builder theme(SpannableTheme theme) { + @NonNull + public Builder theme(@NonNull SpannableTheme theme) { this.theme = theme; return this; } - public Builder asyncDrawableLoader(AsyncDrawable.Loader asyncDrawableLoader) { + @NonNull + public Builder asyncDrawableLoader(@NonNull AsyncDrawable.Loader asyncDrawableLoader) { this.asyncDrawableLoader = asyncDrawableLoader; return this; } - public Builder syntaxHighlight(SyntaxHighlight syntaxHighlight) { + @NonNull + public Builder syntaxHighlight(@NonNull SyntaxHighlight syntaxHighlight) { this.syntaxHighlight = syntaxHighlight; return this; } - public Builder linkResolver(LinkSpan.Resolver linkResolver) { + @NonNull + public Builder linkResolver(@NonNull LinkSpan.Resolver linkResolver) { this.linkResolver = linkResolver; return this; } - public Builder urlProcessor(UrlProcessor urlProcessor) { + @NonNull + public Builder urlProcessor(@NonNull UrlProcessor urlProcessor) { this.urlProcessor = urlProcessor; return this; } - public Builder htmlParser(SpannableHtmlParser htmlParser) { + @NonNull + public Builder htmlParser(@NonNull SpannableHtmlParser htmlParser) { this.htmlParser = htmlParser; return this; } + /** + * @since 1.0.1 + */ + @NonNull + public Builder imageSizeResolver(@NonNull ImageSizeResolver imageSizeResolver) { + this.imageSizeResolver = imageSizeResolver; + return this; + } + + @NonNull public SpannableConfiguration build() { + if (theme == null) { theme = SpannableTheme.create(context); } + if (asyncDrawableLoader == null) { asyncDrawableLoader = new AsyncDrawableLoaderNoOp(); } + if (syntaxHighlight == null) { syntaxHighlight = new SyntaxHighlightNoOp(); } + if (linkResolver == null) { linkResolver = new LinkResolverDef(); } + if (urlProcessor == null) { urlProcessor = new UrlProcessorNoOp(); } + if (htmlParser == null) { - htmlParser = SpannableHtmlParser.create(theme, asyncDrawableLoader, urlProcessor, linkResolver); + + if (imageSizeResolver == null) { + imageSizeResolver = new ImageSizeResolverDef(); + } + + htmlParser = SpannableHtmlParser.create(theme, asyncDrawableLoader, urlProcessor, linkResolver, imageSizeResolver); } + return new SpannableConfiguration(this); } } diff --git a/library/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java b/library/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java index 3e45caee..54a5086f 100644 --- a/library/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java +++ b/library/src/main/java/ru/noties/markwon/renderer/html/ImageProviderImpl.java @@ -1,10 +1,13 @@ package ru.noties.markwon.renderer.html; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.text.SpannableString; import android.text.Spanned; import android.text.TextUtils; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; import ru.noties.markwon.UrlProcessor; @@ -17,14 +20,18 @@ class ImageProviderImpl implements SpannableHtmlParser.ImageProvider { private final SpannableTheme theme; private final AsyncDrawable.Loader loader; private final UrlProcessor urlProcessor; + private final ImageSizeResolver imageSizeResolver; ImageProviderImpl( @NonNull SpannableTheme theme, @NonNull AsyncDrawable.Loader loader, - @NonNull UrlProcessor urlProcessor) { + @NonNull UrlProcessor urlProcessor, + @NonNull ImageSizeResolver imageSizeResolver + ) { this.theme = theme; this.loader = loader; this.urlProcessor = urlProcessor; + this.imageSizeResolver = imageSizeResolver; } @Override @@ -47,7 +54,7 @@ class ImageProviderImpl implements SpannableHtmlParser.ImageProvider { replacement = "\uFFFC"; } - final AsyncDrawable drawable = new AsyncDrawable(destination, loader); + final AsyncDrawable drawable = new AsyncDrawable(destination, loader, imageSizeResolver, parseImageSize(attributes)); final AsyncDrawableSpan span = new AsyncDrawableSpan(theme, drawable); final SpannableString string = new SpannableString(replacement); @@ -60,4 +67,138 @@ class ImageProviderImpl implements SpannableHtmlParser.ImageProvider { return spanned; } + + @Nullable + private static ImageSize parseImageSize(@NonNull Map attributes) { + + final ImageSize imageSize; + + final StyleProvider styleProvider = new StyleProvider(attributes.get("style")); + + final ImageSize.Dimension width = parseDimension(extractDimension("width", attributes, styleProvider)); + final ImageSize.Dimension height = parseDimension(extractDimension("height", attributes, styleProvider)); + + if (width == null + && height == null) { + imageSize = null; + } else { + imageSize = new ImageSize(width, height); + } + + return imageSize; + } + + @Nullable + private static String extractDimension(@NonNull String name, @NonNull Map attributes, @NonNull StyleProvider styleProvider) { + + final String out; + + final String inline = attributes.get(name); + if (!TextUtils.isEmpty(inline)) { + out = inline; + } else { + out = extractDimensionFromStyle(name, styleProvider); + } + + return out; + } + + @Nullable + private static String extractDimensionFromStyle(@NonNull String name, @NonNull StyleProvider styleProvider) { + return styleProvider.attributes().get(name); + } + + @Nullable + private static ImageSize.Dimension parseDimension(@Nullable String raw) { + + // a set of digits, then dimension unit (allow floating) + + final ImageSize.Dimension dimension; + + final int length = raw != null + ? raw.length() + : 0; + + if (length == 0) { + dimension = null; + } else { + + // first digit to find -> unit is finished (can be null) + + int index = -1; + + for (int i = length - 1; i >= 0; i--) { + if (Character.isDigit(raw.charAt(i))) { + index = i; + break; + } + } + + // no digits -> no dimension + if (index == -1) { + dimension = null; + } else { + + final String value; + final String unit; + + // no unit is specified + if (index == length - 1) { + value = raw; + unit = null; + } else { + value = raw.substring(0, index + 1); + unit = raw.substring(index + 1); + } + + ImageSize.Dimension inner; + try { + final float floatValue = Float.parseFloat(value); + inner = new ImageSize.Dimension(floatValue, unit); + } catch (NumberFormatException e) { + inner = null; + } + + dimension = inner; + } + } + + return dimension; + } + + private static class StyleProvider { + + private final String style; + private Map attributes; + + StyleProvider(@Nullable String style) { + this.style = style; + } + + @NonNull + Map attributes() { + final Map out; + if (attributes != null) { + out = attributes; + } else { + if (TextUtils.isEmpty(style)) { + out = attributes = Collections.emptyMap(); + } else { + final String[] split = style.split(";"); + final Map map = new HashMap<>(split.length); + String[] parts; + for (String s : split) { + if (!TextUtils.isEmpty(s)) { + parts = s.split(":"); + if (parts.length == 2) { + map.put(parts[0].trim(), parts[1].trim()); + } + } + } + out = attributes = map; + } + } + return out; + } + } } diff --git a/library/src/main/java/ru/noties/markwon/renderer/html/ImageSize.java b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSize.java new file mode 100644 index 00000000..f65144ab --- /dev/null +++ b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSize.java @@ -0,0 +1,37 @@ +package ru.noties.markwon.renderer.html; + +import android.support.annotation.Nullable; + +/** + * @since 1.0.1 + */ +@SuppressWarnings("WeakerAccess") +public class ImageSize { + + public static class Dimension { + + public final float value; + public final String unit; + + public Dimension(float value, @Nullable String unit) { + this.value = value; + this.unit = unit; + } + + @Override + public String toString() { + return "Dimension{" + + "value=" + value + + ", unit='" + unit + '\'' + + '}'; + } + } + + public final Dimension width; + public final Dimension height; + + public ImageSize(@Nullable Dimension width, @Nullable Dimension height) { + this.width = width; + this.height = height; + } +} diff --git a/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolver.java b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolver.java new file mode 100644 index 00000000..41574bb7 --- /dev/null +++ b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolver.java @@ -0,0 +1,29 @@ +package ru.noties.markwon.renderer.html; + +import android.graphics.Rect; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * @since 1.0.1 + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +public abstract class ImageSizeResolver { + + /** + * We do not expose canvas height deliberately. As we cannot rely on this value very much + * + * @param imageSize {@link ImageSize} parsed from HTML + * @param imageBounds original image bounds + * @param canvasWidth width of the canvas + * @param textSize current font size + * @return resolved image bounds + */ + @NonNull + public abstract Rect resolveImageSize( + @Nullable ImageSize imageSize, + @NonNull Rect imageBounds, + int canvasWidth, + float textSize + ); +} diff --git a/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolverDef.java b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolverDef.java new file mode 100644 index 00000000..b7eb1fc9 --- /dev/null +++ b/library/src/main/java/ru/noties/markwon/renderer/html/ImageSizeResolverDef.java @@ -0,0 +1,85 @@ +package ru.noties.markwon.renderer.html; + +import android.graphics.Rect; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +/** + * @since 1.0.1 + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +public class ImageSizeResolverDef extends ImageSizeResolver { + + // we track these two, others are considered to be pixels + protected static final String UNIT_PERCENT = "%"; + protected static final String UNIT_EM = "em"; + + @NonNull + @Override + public Rect resolveImageSize( + @Nullable ImageSize imageSize, + @NonNull Rect imageBounds, + int canvasWidth, + float textSize + ) { + + if (imageSize == null) { + return imageBounds; + } + + final Rect rect; + + final ImageSize.Dimension width = imageSize.width; + final ImageSize.Dimension height = imageSize.height; + + final int imageWidth = imageBounds.width(); + final int imageHeight = imageBounds.height(); + + final float ratio = (float) imageWidth / imageHeight; + + if (width != null) { + + final int w; + final int h; + + if (UNIT_PERCENT.equals(width.unit)) { + w = (int) (canvasWidth * (width.value / 100.F) + .5F); + } else { + w = resolveAbsolute(width, imageWidth, textSize); + } + + if (height == null + || UNIT_PERCENT.equals(height.unit)) { + h = (int) (w / ratio + .5F); + } else { + h = resolveAbsolute(height, imageHeight, textSize); + } + + rect = new Rect(0, 0, w, h); + + } else if (height != null) { + + if (!UNIT_PERCENT.equals(height.unit)) { + final int h = resolveAbsolute(height, imageHeight, textSize); + final int w = (int) (h * ratio + .5F); + rect = new Rect(0, 0, w, h); + } else { + rect = imageBounds; + } + } else { + rect = imageBounds; + } + + return rect; + } + + protected int resolveAbsolute(@NonNull ImageSize.Dimension dimension, int original, float textSize) { + final int out; + if (UNIT_EM.equals(dimension.unit)) { + out = (int) (dimension.value * textSize + .5F); + } else { + out = original; + } + return out; + } +} diff --git a/library/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java b/library/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java index af616330..6423e1f9 100644 --- a/library/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java +++ b/library/src/main/java/ru/noties/markwon/renderer/html/SpannableHtmlParser.java @@ -21,37 +21,74 @@ import ru.noties.markwon.spans.SpannableTheme; public class SpannableHtmlParser { // creates default parser + @NonNull public static SpannableHtmlParser create( @NonNull SpannableTheme theme, @NonNull AsyncDrawable.Loader loader ) { - return builderWithDefaults(theme, loader, null, null) + return builderWithDefaults(theme, loader, null, null, null) .build(); } + /** + * @since 1.0.1 + */ + @NonNull + public static SpannableHtmlParser create( + @NonNull SpannableTheme theme, + @NonNull AsyncDrawable.Loader loader, + @NonNull ImageSizeResolver imageSizeResolver + ) { + return builderWithDefaults(theme, loader, null, null, imageSizeResolver) + .build(); + } + + @NonNull public static SpannableHtmlParser create( @NonNull SpannableTheme theme, @NonNull AsyncDrawable.Loader loader, @NonNull UrlProcessor urlProcessor, @NonNull LinkSpan.Resolver resolver ) { - return builderWithDefaults(theme, loader, urlProcessor, resolver) + return builderWithDefaults(theme, loader, urlProcessor, resolver, null) .build(); } + /** + * @since 1.0.1 + */ + @NonNull + public static SpannableHtmlParser create( + @NonNull SpannableTheme theme, + @NonNull AsyncDrawable.Loader loader, + @NonNull UrlProcessor urlProcessor, + @NonNull LinkSpan.Resolver resolver, + @NonNull ImageSizeResolver imageSizeResolver + ) { + return builderWithDefaults(theme, loader, urlProcessor, resolver, imageSizeResolver) + .build(); + } + + @NonNull public static Builder builder() { return new Builder(); } + @NonNull public static Builder builderWithDefaults(@NonNull SpannableTheme theme) { - return builderWithDefaults(theme, null, null, null); + return builderWithDefaults(theme, null, null, null, null); } + /** + * Updated in 1.0.1: added imageSizeResolverArgument + */ + @NonNull public static Builder builderWithDefaults( @NonNull SpannableTheme theme, @Nullable AsyncDrawable.Loader asyncDrawableLoader, @Nullable UrlProcessor urlProcessor, - @Nullable LinkSpan.Resolver resolver + @Nullable LinkSpan.Resolver resolver, + @Nullable ImageSizeResolver imageSizeResolver ) { if (urlProcessor == null) { @@ -68,7 +105,12 @@ public class SpannableHtmlParser { final ImageProvider imageProvider; if (asyncDrawableLoader != null) { - imageProvider = new ImageProviderImpl(theme, asyncDrawableLoader, urlProcessor); + + if (imageSizeResolver == null) { + imageSizeResolver = new ImageSizeResolverDef(); + } + + imageProvider = new ImageProviderImpl(theme, asyncDrawableLoader, urlProcessor, imageSizeResolver); } else { imageProvider = null; } @@ -163,21 +205,25 @@ public class SpannableHtmlParser { private ImageProvider imageProvider; private HtmlParser parser; + @NonNull Builder simpleTag(@NonNull String tag, @NonNull SpanProvider provider) { simpleTags.put(tag, provider); return this; } - public Builder imageProvider(ImageProvider imageProvider) { + @NonNull + public Builder imageProvider(@Nullable ImageProvider imageProvider) { this.imageProvider = imageProvider; return this; } + @NonNull public Builder parser(@NonNull HtmlParser parser) { this.parser = parser; return this; } + @NonNull public SpannableHtmlParser build() { if (parser == null) { parser = DefaultHtmlParser.create(); diff --git a/library/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java b/library/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java index b466e194..2253a5ba 100644 --- a/library/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java +++ b/library/src/main/java/ru/noties/markwon/spans/AsyncDrawable.java @@ -3,15 +3,20 @@ package ru.noties.markwon.spans; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.PixelFormat; +import android.graphics.Rect; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import ru.noties.markwon.renderer.html.ImageSize; +import ru.noties.markwon.renderer.html.ImageSizeResolver; + public class AsyncDrawable extends Drawable { public interface Loader { + void load(@NonNull String destination, @NonNull AsyncDrawable drawable); void cancel(@NonNull String destination); @@ -19,13 +24,32 @@ public class AsyncDrawable extends Drawable { private final String destination; private final Loader loader; + private final ImageSize imageSize; + private final ImageSizeResolver imageSizeResolver; private Drawable result; private Callback callback; + private int canvasWidth; + private float textSize; + public AsyncDrawable(@NonNull String destination, @NonNull Loader loader) { + this(destination, loader, null, null); + } + + /** + * @since 1.0.1 + */ + public AsyncDrawable( + @NonNull String destination, + @NonNull Loader loader, + @Nullable ImageSizeResolver imageSizeResolver, + @Nullable ImageSize imageSize + ) { this.destination = destination; this.loader = loader; + this.imageSizeResolver = imageSizeResolver; + this.imageSize = imageSize; } public String getDestination() { @@ -77,13 +101,22 @@ public class AsyncDrawable extends Drawable { this.result = result; this.result.setCallback(callback); - // should we copy the data here? like bounds etc? - // if we are async and we load some image from some source - // thr bounds might change... so we are better off copy `result` bounds to this instance - setBounds(result.getBounds()); + final Rect bounds = resolveBounds(); + result.setBounds(bounds); + setBounds(bounds); + invalidateSelf(); } + /** + * @since 1.0.1 + */ + @SuppressWarnings("WeakerAccess") + public void initWithKnownDimensions(int width, float textSize) { + this.canvasWidth = width; + this.textSize = textSize; + } + @Override public void draw(@NonNull Canvas canvas) { if (hasResult()) { @@ -133,4 +166,19 @@ public class AsyncDrawable extends Drawable { } return out; } + + /** + * @since 1.0.1 + */ + @NonNull + private Rect resolveBounds() { + final Rect rect; + if (imageSizeResolver == null + || imageSize == null) { + rect = result.getBounds(); + } else { + rect = imageSizeResolver.resolveImageSize(imageSize, result.getBounds(), canvasWidth, textSize); + } + return rect; + } } diff --git a/library/src/main/java/ru/noties/markwon/spans/AsyncDrawableSpan.java b/library/src/main/java/ru/noties/markwon/spans/AsyncDrawableSpan.java index c74a1305..f9bd8f33 100644 --- a/library/src/main/java/ru/noties/markwon/spans/AsyncDrawableSpan.java +++ b/library/src/main/java/ru/noties/markwon/spans/AsyncDrawableSpan.java @@ -109,6 +109,8 @@ public class AsyncDrawableSpan extends ReplacementSpan { int bottom, @NonNull Paint paint) { + drawable.initWithKnownDimensions(canvas.getWidth(), paint.getTextSize()); + final AsyncDrawable drawable = this.drawable; if (drawable.hasResult()) {