diff --git a/CHANGELOG.md b/CHANGELOG.md index 005a79c8..1713a4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +# Snapshot + +#### Added +* `image` - `DefaultDownScalingMediaDecoder` which scales displayed images down ([#329]) + +[#329]: https://github.com/noties/Markwon/issues/329 + + # 4.6.1 #### Changed diff --git a/app-sample/samples.json b/app-sample/samples.json index 23fcb84b..7be52b51 100644 --- a/app-sample/samples.json +++ b/app-sample/samples.json @@ -1,4 +1,16 @@ [ + { + "javaClassName": "io.noties.markwon.app.samples.image.HugeImageSample", + "id": "20210118165230", + "title": "Huge image downscaling", + "description": "Downscale displayed images with `BitmapOptions` 2 step rendering (measure, downscale), use `DefaultDownScalingMediaDecoder`", + "artifacts": [ + "IMAGE" + ], + "tags": [ + "image" + ] + }, { "javaClassName": "io.noties.markwon.app.samples.html.HtmlCssStyleParserSample", "id": "20210118155530", diff --git a/app-sample/src/main/java/io/noties/markwon/app/samples/image/HugeImageSample.java b/app-sample/src/main/java/io/noties/markwon/app/samples/image/HugeImageSample.java new file mode 100644 index 00000000..8e158f83 --- /dev/null +++ b/app-sample/src/main/java/io/noties/markwon/app/samples/image/HugeImageSample.java @@ -0,0 +1,69 @@ +package io.noties.markwon.app.samples.image; + +import android.view.ViewTreeObserver; + +import io.noties.markwon.Markwon; +import io.noties.markwon.app.sample.Tags; +import io.noties.markwon.app.sample.ui.MarkwonTextViewSample; +import io.noties.markwon.image.DefaultDownScalingMediaDecoder; +import io.noties.markwon.image.ImagesPlugin; +import io.noties.markwon.sample.annotations.MarkwonArtifact; +import io.noties.markwon.sample.annotations.MarkwonSampleInfo; + +@MarkwonSampleInfo( + id = "20210118165230", + title = "Huge image downscaling", + description = "Downscale displayed images with `BitmapOptions` 2 step rendering " + + "(measure, downscale), use `DefaultDownScalingMediaDecoder`", + artifacts = MarkwonArtifact.IMAGE, + tags = Tags.image +) +public class HugeImageSample extends MarkwonTextViewSample { + @Override + public void render() { + + // NB! this is based on the width of the widget. In case you have big vertical + // images (with big vertical dimension, use some reasonable value or fallback to real OpenGL + // maximum, see: https://stackoverflow.com/questions/15313807/android-maximum-allowed-width-height-of-bitmap + + final int width = textView.getWidth(); + if (width > 0) { + renderWithMaxWidth(width); + return; + } + + textView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + final int w = textView.getWidth(); + if (w > 0) { + renderWithMaxWidth(w); + + final ViewTreeObserver observer = textView.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnPreDrawListener(this); + } + } + return true; + } + }); + } + + private void renderWithMaxWidth(int maxWidth) { + + final String md = "" + + "# Huge image\n\n" + + "![this is alt](https://otakurevolution.com/storyimgs/falldog/GundamTimeline/Falldogs_GundamTimeline_v13_April2020.png)\n\n" + + "hey!"; + + final Markwon markwon = Markwon.builder(context) + .usePlugin(ImagesPlugin.create(plugin -> { + plugin + .defaultMediaDecoder(DefaultDownScalingMediaDecoder.create(maxWidth, 0)); + })) + .build(); + + markwon.setMarkdown(textView, md); + } + +} diff --git a/gradle.properties b/gradle.properties index ea77f9f8..78c2b1f6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.enableJetifier=true android.enableBuildCache=true android.buildCacheDir=build/pre-dex-cache -VERSION_NAME=4.6.1 +VERSION_NAME=4.6.2-SNAPSHOT GROUP=io.noties.markwon POM_DESCRIPTION=Markwon markdown for Android diff --git a/markwon-image/src/main/java/io/noties/markwon/image/DefaultDownScalingMediaDecoder.java b/markwon-image/src/main/java/io/noties/markwon/image/DefaultDownScalingMediaDecoder.java new file mode 100644 index 00000000..2d7d49de --- /dev/null +++ b/markwon-image/src/main/java/io/noties/markwon/image/DefaultDownScalingMediaDecoder.java @@ -0,0 +1,173 @@ +package io.noties.markwon.image; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Collection; +import java.util.Collections; + +/** + * A {@link MediaDecoder} that additionally process media resource to optionally + * scale it down to fit specified maximum values. Should be used to ensure that no exception is raised + * whilst rendering ({@code Canvas: trying to draw too large(Xbytes) bitmap}) or {@code OutOfMemoryException} is thrown. + * + * NB this media decoder will create a temporary file for each incoming media resource, + * which can have a performance penalty (IO) + * + * @since $SNAPSHOT; + */ +public class DefaultDownScalingMediaDecoder extends MediaDecoder { + + /** + * Values {@code <= 0} are ignored, a dimension is considered to be not restrained any limit in such case + */ + @NonNull + public static DefaultDownScalingMediaDecoder create(int maxWidth, int maxHeight) { + return create(Resources.getSystem(), maxWidth, maxHeight); + } + + @NonNull + public static DefaultDownScalingMediaDecoder create( + @NonNull Resources resources, + int maxWidth, + int maxHeight + ) { + return new DefaultDownScalingMediaDecoder(resources, maxWidth, maxHeight); + } + + private final Resources resources; + private final int maxWidth; + private final int maxHeight; + + private DefaultDownScalingMediaDecoder(@NonNull Resources resources, int maxWidth, int maxHeight) { + this.resources = resources; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + } + + // https://android.jlelse.eu/loading-large-bitmaps-efficiently-in-android-66826cd4ad53 + @NonNull + @Override + public Drawable decode(@Nullable String contentType, @NonNull InputStream inputStream) { + + final File file = writeToTempFile(inputStream); + try { + + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + BitmapFactory + .decodeStream(readFile(file), null, options); + + options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight); + options.inJustDecodeBounds = false; + + final Bitmap bitmap = BitmapFactory + .decodeStream(readFile(file), null, options); + + return new BitmapDrawable(resources, bitmap); + } finally { + // we no longer need the temporary file + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + + @NonNull + private static File writeToTempFile(@NonNull InputStream inputStream) { + final File file; + try { + file = File.createTempFile("markwon", null); + } catch (IOException e) { + throw new IllegalStateException(e); + } + + final OutputStream outputStream; + try { + outputStream = new BufferedOutputStream(new FileOutputStream(file, false)); + } catch (FileNotFoundException e) { + throw new IllegalStateException(e); + } + + final byte[] buffer = new byte[1024 * 8]; + int length; + try { + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } finally { + try { + outputStream.close(); + } catch (IOException e) { + // ignored + } + } + + return file; + } + + @NonNull + private static InputStream readFile(@NonNull File file) { + try { + return new BufferedInputStream(new FileInputStream(file)); + } catch (FileNotFoundException e) { + throw new IllegalStateException(e); + } + } + + // see: https://developer.android.com/topic/performance/graphics/load-bitmap.html#load-bitmap + private static int calculateInSampleSize(@NonNull BitmapFactory.Options options, int maxWidth, int maxHeight) { + final int w = options.outWidth; + final int h = options.outHeight; + + final boolean hasMaxWidth = maxWidth > 0; + final boolean hasMaxHeight = maxHeight > 0; + + final int inSampleSize; + if (hasMaxWidth && hasMaxHeight) { + // minimum of both + inSampleSize = Math.min(calculateInSampleSize(w, maxWidth), calculateInSampleSize(h, maxHeight)); + } else if (hasMaxWidth) { + inSampleSize = calculateInSampleSize(w, maxWidth); + } else if (hasMaxHeight) { + inSampleSize = calculateInSampleSize(h, maxHeight); + } else { + // else no sampling, as we have no dimensions to base our calculations on + inSampleSize = 1; + } + + return inSampleSize; + } + + private static int calculateInSampleSize(int actual, int max) { + int inSampleSize = 1; + final int half = actual / 2; + while ((half / inSampleSize) > max) { + inSampleSize *= 2; + } + return inSampleSize; + } + + @NonNull + @Override + public Collection supportedTypes() { + return Collections.emptySet(); + } +} diff --git a/markwon-image/src/main/java/io/noties/markwon/image/DefaultMediaDecoder.java b/markwon-image/src/main/java/io/noties/markwon/image/DefaultMediaDecoder.java index 0fc18824..e60c225e 100644 --- a/markwon-image/src/main/java/io/noties/markwon/image/DefaultMediaDecoder.java +++ b/markwon-image/src/main/java/io/noties/markwon/image/DefaultMediaDecoder.java @@ -17,6 +17,10 @@ import java.util.Collections; * This class can be used as the last {@link MediaDecoder} to _try_ to handle all rest cases. * Here we just assume that supplied InputStream is of image type and try to decode it. * + * NB if you are dealing with big images that require down scaling see {@link DefaultDownScalingMediaDecoder} + * which additionally down scales displayed images. + * + * @see DefaultDownScalingMediaDecoder * @since 1.1.0 */ public class DefaultMediaDecoder extends MediaDecoder {