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" +
+ "\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 {