From 79ca1e58af7ca831e2f4ace911257c6b1315cb2e Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 24 Jul 2026 15:46:14 +0200 Subject: [PATCH 1/5] feat: support loading local images --- .../enriched/markdown/spans/ImageSpan.kt | 23 ++- .../markdown/utils/text/ImageDownloader.kt | 5 + .../markdown/utils/text/LocalImageLoader.kt | 158 ++++++++++++++++++ .../ios/utils/ENRMImageDownloader.m | 10 ++ .../ios/utils/ENRMLocalImageLoader.h | 31 ++++ .../ios/utils/ENRMLocalImageLoader.m | 68 ++++++++ 6 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt create mode 100644 packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.h create mode 100644 packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.m diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt index 224b8d8d..28657ae4 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt @@ -7,6 +7,7 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Build import android.text.Spannable import android.text.Spanned @@ -20,6 +21,7 @@ import androidx.core.graphics.withSave import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.text.ImageCache import com.swmansion.enriched.markdown.utils.text.ImageDownloader +import com.swmansion.enriched.markdown.utils.text.LocalImageLoader import java.lang.ref.WeakReference import java.util.concurrent.Executors import android.text.style.ImageSpan as AndroidImageSpan @@ -52,7 +54,8 @@ class ImageSpan( } private fun loadImage() { - if (imageUrl.startsWith("http")) { + val scheme = Uri.parse(imageUrl).scheme?.lowercase() + if (scheme == "http" || scheme == "https") { ImageDownloader.download(context, imageUrl, requestHeaders) { bitmap -> if (bitmap != null) { sourceDrawable = bitmap.toDrawable(context.resources) @@ -60,17 +63,12 @@ class ImageSpan( } } } else { - val path = imageUrl.removePrefix("file://") - try { - val cached = ImageCache.getOriginal(imageUrl) - val bitmap = cached ?: ImageDownloader.decodeFileDownsampled(context, path) - if (bitmap != null) { - if (cached == null) ImageCache.putOriginal(imageUrl, bitmap) - sourceDrawable = bitmap.toDrawable(context.resources) - wrapAndAssignDrawable() - } - } catch (e: Exception) { - Log.w(TAG, "Failed to load local image: $path", e) + val cached = ImageCache.getOriginal(imageUrl) + val bitmap = cached ?: LocalImageLoader.load(context, imageUrl) + if (bitmap != null) { + if (cached == null) ImageCache.putOriginal(imageUrl, bitmap) + sourceDrawable = bitmap.toDrawable(context.resources) + wrapAndAssignDrawable() } } } @@ -330,7 +328,6 @@ class ImageSpan( } companion object { - private const val TAG = "ImageSpan" private val transparentDrawable by lazy { Color.TRANSPARENT.toDrawable() } private val cacheExecutor = Executors.newSingleThreadExecutor() } diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt index ad644222..7a2071e4 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt @@ -121,6 +121,11 @@ object ImageDownloader { } } + fun decodeBytesDownsampled( + context: Context, + bytes: ByteArray, + ): Bitmap? = decodeDownsampled(bytes, context.resources.displayMetrics.widthPixels) + fun decodeFileDownsampled( context: Context, path: String, diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt new file mode 100644 index 00000000..661dcca3 --- /dev/null +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt @@ -0,0 +1,158 @@ +package com.swmansion.enriched.markdown.utils.text + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Base64 +import android.util.Log +import java.io.FileNotFoundException +import java.io.InputStream + +/** + * Loads markdown images from non-network sources (issue #377). + * + * React Native's asset system produces different URI shapes for the same + * `require('./img.png')` depending on environment, and none of them are plain + * file paths in a bundled app: + * - Metro dev server: `http://host:8081/assets/...` (handled by [ImageDownloader]) + * - Release APK: a bare drawable resource name, e.g. `src_assets_logo` + * (RN's `AssetSourceResolver.resourceIdentifierWithoutScale`) + * - Sideloaded JS bundle: `file:///.../drawable-xhdpi/src_assets_logo.png` + * - expo-updates embedded assets: `file:///android_res/drawable/.png` + * - expo-asset downloads: percent-encoded `file://` paths in the cache dir + * + * Resolution mirrors RN core: `ImageSource.computeUri` treats a scheme-less + * source as a resource name, and `ResourceDrawableIdHelper` normalizes names + * with lowercase + `-`→`_` and accepts numeric resource ids. The + * `android_res`/`android_asset` and drawable/raw lookups follow expo-asset's + * `ResourceAsset.kt`. `content://`, `asset://`, `res://` and `data:` URIs are + * supported for parity with RN's Fresco pipeline. All decodes are downsampled + * to screen width like the network path. + */ +object LocalImageLoader { + private const val TAG = "LocalImageLoader" + private const val ASSET_PATH_PREFIX = "/android_asset/" + private const val RES_PATH_PREFIX = "/android_res/" + private const val BASE64_MARKER = "base64," + + fun load( + context: Context, + source: String, + ): Bitmap? = + try { + val uri = Uri.parse(source) + when (uri.scheme?.lowercase()) { + null -> { + if (source.startsWith('/')) { + ImageDownloader.decodeFileDownsampled(context, source) + } else { + decodeResourceByName(context, source) + } + } + + "file" -> { + loadFileUri(context, uri) + } + + "asset" -> { + decodeStream(context) { context.assets.open(schemelessPath(uri)) } + } + + "content" -> { + decodeStream(context) { + context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException(source) + } + } + + "res" -> { + decodeResourceByName(context, schemelessPath(uri)) + } + + "data" -> { + decodeDataUri(context, source) + } + + else -> { + Log.w(TAG, "Unsupported image URI scheme: $source") + null + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to load local image: $source", e) + null + } + + private fun loadFileUri( + context: Context, + uri: Uri, + ): Bitmap? { + val path = uri.path ?: return null + return when { + path.startsWith(ASSET_PATH_PREFIX) -> { + decodeStream(context) { context.assets.open(path.removePrefix(ASSET_PATH_PREFIX)) } + } + + path.startsWith(RES_PATH_PREFIX) -> { + decodeAndroidRes(context, uri) + } + + else -> { + ImageDownloader.decodeFileDownsampled(context, path) + } + } + } + + /** + * Resolves `file:///android_res/[-qualifier]/[.]` by stripping + * density qualifiers and the file extension, then looking the resource up by name. + */ + private fun decodeAndroidRes( + context: Context, + uri: Uri, + ): Bitmap? { + val segments = uri.pathSegments + if (segments.size < 3) return null + val directory = segments[1].substringBefore('-') + val name = segments[2].substringBeforeLast('.') + val resId = context.resources.getIdentifier(name, directory, context.packageName) + if (resId == 0) { + Log.w(TAG, "No $directory resource named: $name") + return null + } + return decodeStream(context) { context.resources.openRawResource(resId) } + } + + private fun decodeResourceByName( + context: Context, + name: String, + ): Bitmap? { + if (name.isEmpty()) return null + val normalized = name.lowercase().replace('-', '_') + val resId = + normalized.toIntOrNull() + ?: context.resources.getIdentifier(normalized, "drawable", context.packageName).takeIf { it != 0 } + ?: context.resources.getIdentifier(normalized, "raw", context.packageName) + if (resId == 0) { + Log.w(TAG, "No drawable or raw resource named: $normalized") + return null + } + return decodeStream(context) { context.resources.openRawResource(resId) } + } + + private fun decodeDataUri( + context: Context, + source: String, + ): Bitmap? { + val marker = source.indexOf(BASE64_MARKER) + if (marker == -1) return null + val bytes = Base64.decode(source.substring(marker + BASE64_MARKER.length), Base64.DEFAULT) + return ImageDownloader.decodeBytesDownsampled(context, bytes) + } + + private inline fun decodeStream( + context: Context, + open: () -> InputStream, + ): Bitmap? = open().use { ImageDownloader.decodeBytesDownsampled(context, it.readBytes()) } + + private fun schemelessPath(uri: Uri): String = (uri.host.orEmpty() + uri.path.orEmpty()).trimStart('/') +} diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m index 7894e809..e9b57cc3 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m @@ -1,5 +1,6 @@ #import "ENRMImageDownloader.h" #import "ENRMImageAttachment.h" +#import "ENRMLocalImageLoader.h" #import #include @@ -82,6 +83,15 @@ - (void)downloadURL:(NSString *)url return; } + if (ENRMIsLocalImageURL(url)) { + RCTUIImage *local = ENRMLoadLocalImage(url); + if (local) { + [[ENRMImageAttachment originalImageCache] setObject:local forKey:cacheKey cost:ENRMImageByteCost(local)]; + } + completion(local); + return; + } + @synchronized(_inFlightRequests) { NSMutableArray *existing = _inFlightRequests[cacheKey]; if (existing) { diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.h b/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.h new file mode 100644 index 00000000..141bcd19 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.h @@ -0,0 +1,31 @@ +#pragma once +#import "ENRMUIKit.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Local (non-network) image loading for markdown sources (issue #377). + * + * React Native's asset system resolves `require('./img.png')` to a Metro http + * URL in dev, but to a `file://` URL near the app bundle in release builds + * (`AssetSourceResolver.scaledAssetURLNearBundle`), and libraries like + * expo-asset also hand out percent-encoded `file://` paths in the caches + * directory. These should not go through NSURLSession + NSURLCache: they gain + * nothing from HTTP caching or timeouts, and bundle assets lose `@2x`/`@3x` + * scale selection and asset-catalog resolution. + * + * Resolution mirrors RN's RCTImageFromLocalAssetURL: bundle-relative names are + * tried with `imageNamed:` first (scale suffixes, asset catalogs, and system + * caching for free), then the loader falls back to loading the file directly, + * appending `.png` when the path has no extension. + */ + +/// YES when the URL refers to a non-network source: any `file://` URL, or a +/// scheme-less string (bare bundle-relative asset name or absolute path). +BOOL ENRMIsLocalImageURL(NSString *url); + +/// Synchronously loads a local image; returns nil when the source cannot be +/// resolved. Safe to call off the main thread. +RCTUIImage *_Nullable ENRMLoadLocalImage(NSString *url); + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.m b/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.m new file mode 100644 index 00000000..68667306 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMLocalImageLoader.m @@ -0,0 +1,68 @@ +#import "ENRMLocalImageLoader.h" + +static NSString *_Nullable ENRMBundleRelativePath(NSString *path) +{ + NSString *bundlePath = [[NSBundle mainBundle] resourcePath]; + if (bundlePath == nil || ![path hasPrefix:bundlePath]) { + return nil; + } + NSString *relative = [path substringFromIndex:bundlePath.length]; + return [relative hasPrefix:@"/"] ? [relative substringFromIndex:1] : relative; +} + +static RCTUIImage *_Nullable ENRMImageNamed(NSString *name) +{ + return [RCTUIImage imageNamed:name]; +} + +static RCTUIImage *_Nullable ENRMImageAtPath(NSString *path) +{ + if (path.pathExtension.length == 0) { + path = [path stringByAppendingPathExtension:@"png"]; + } +#if !TARGET_OS_OSX + return [RCTUIImage imageWithContentsOfFile:path]; +#else + return [[RCTUIImage alloc] initWithContentsOfFile:path]; +#endif +} + +BOOL ENRMIsLocalImageURL(NSString *url) +{ + if ([url hasPrefix:@"file://"]) { + return YES; + } + NSURL *parsed = [NSURL URLWithString:url]; + return parsed == nil || parsed.scheme.length == 0; +} + +RCTUIImage *_Nullable ENRMLoadLocalImage(NSString *url) +{ + NSString *filePath = nil; + NSString *imageName = nil; + + if ([url hasPrefix:@"file://"]) { + NSURL *fileURL = [NSURL URLWithString:url]; + if (fileURL.fileURL) { + filePath = @(fileURL.fileSystemRepresentation); + } else { + NSString *stripped = [url substringFromIndex:@"file://".length]; + filePath = [stripped stringByRemovingPercentEncoding] ?: stripped; + } + imageName = ENRMBundleRelativePath(filePath); + } else { + NSString *decoded = [url stringByRemovingPercentEncoding] ?: url; + if (decoded.absolutePath) { + filePath = decoded; + } else { + imageName = decoded; + filePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:decoded]; + } + } + + RCTUIImage *image = imageName.length > 0 ? ENRMImageNamed(imageName) : nil; + if (image == nil && filePath.length > 0) { + image = ENRMImageAtPath(filePath); + } + return image; +} From 7be10a63a743a5dcd20c2e147046141a2ecb2672 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 24 Jul 2026 15:46:46 +0200 Subject: [PATCH 2/5] docs: storybook --- .../block/Image.stories.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Image.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Image.stories.tsx index 4525dec1..ee1c18c8 100644 --- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Image.stories.tsx +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/Image.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Image } from 'react-native'; import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory'; import { storyMeta } from '../shared/storyMeta'; import { @@ -15,6 +16,13 @@ import type { TextStory } from '../shared/storyTypes'; const MARKDOWN = '![Misty forest at sunrise](https://images.unsplash.com/photo-1448375240586-882707db888b?w=800)'; +const LOCAL_ASSET_URI = Image.resolveAssetSource( + require('../../../../../src/assets/logo.png') +).uri; + +const DATA_URI = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeklEQVR42u3asQnAIBQE0D9JILhDyFwZyUW1E4uki5jgg19e4auOAyPtx68vAABeBEQu7c7tur0+MysPADAK8LWHPuUBAAAAAMYANDEAAACAPaCJAQAAAOwBTQwAAABgD2hiAAAAAHtAEwMAAADYA5oYYCWAn7sACwIqZZCNkUg0NTIAAAAASUVORK5CYII='; + const argTypes = { height: numberControl('markdownStyle.image.height', { min: 80, @@ -73,3 +81,41 @@ export const Default: TextStory = { ); }, }; + +export const LocalAsset: TextStory = { + args: { + markdown: `![Bundled logo](${LOCAL_ASSET_URI})`, + ...imageStyledDefaults, + }, + argTypes, + render: (args) => { + const { controls, rest } = splitStyleControls(args, imageStyledDefaults); + return ( + + ); + }, +}; + +export const DataUri: TextStory = { + args: { + markdown: `![Checkerboard](${DATA_URI})`, + ...imageStyledDefaults, + }, + argTypes, + render: (args) => { + const { controls, rest } = splitStyleControls(args, imageStyledDefaults); + return ( + + ); + }, +}; From e02ae73007d26f8e3e2d1617f3f7c99870f7d457 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 24 Jul 2026 15:50:19 +0200 Subject: [PATCH 3/5] docs: docs --- docs/IMAGE_CACHING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/IMAGE_CACHING.md b/docs/IMAGE_CACHING.md index 93f57f42..ba899570 100644 --- a/docs/IMAGE_CACHING.md +++ b/docs/IMAGE_CACHING.md @@ -2,6 +2,30 @@ Images in Markdown content are loaded, cached, and reused automatically — no configuration required. +## Supported Image Sources + +Markdown image URLs are strings, so bundled assets must be resolved to a URI before being interpolated into the markdown: + +```tsx +import { Image } from 'react-native'; + +const logoUri = Image.resolveAssetSource(require('./assets/logo.png')).uri; +const markdown = `![Logo](${logoUri})`; +``` + +This works in every environment: in dev the URI is a Metro dev-server URL, while in a release build it resolves to a drawable resource name on Android and a bundle `file://` URL on iOS — all of which are supported. URIs from `expo-asset` (`asset.uri` / `asset.localUri`) work the same way. + +| Source | Android | iOS / macOS | +|---|---|---| +| `http://`, `https://` | Yes | Yes | +| `file://` (including percent-encoded paths) | Yes | Yes | +| Bare asset name (release builds, expo-asset `localUri`) | Yes (drawable/raw resource) | Yes (bundle resource) | +| `file:///android_res/…`, `file:///android_asset/…` | Yes | No | +| `asset://`, `res://`, `content://` | Yes | No | +| `data:` (base64) | Yes | Yes | + +Local sources skip the HTTP layers below (disk cache, request headers, deduplication) but still use both memory cache tiers. + ## Cache Layers The library uses a three-tier caching strategy on both platforms: From d7ec5723f86181f8ee577974540af5409dff3595 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 24 Jul 2026 16:53:51 +0200 Subject: [PATCH 4/5] feat: rn android --- .../markdown/utils/text/LocalImageLoader.kt | 2 +- .../enriched/markdown/spans/ImageSpan.kt | 23 ++- .../markdown/utils/text/ImageDownloader.kt | 5 + .../markdown/utils/text/LocalImageLoader.kt | 158 ++++++++++++++++++ 4 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt index 661dcca3..133b36f5 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt @@ -23,7 +23,7 @@ import java.io.InputStream * * Resolution mirrors RN core: `ImageSource.computeUri` treats a scheme-less * source as a resource name, and `ResourceDrawableIdHelper` normalizes names - * with lowercase + `-`→`_` and accepts numeric resource ids. The + * by lowercasing, replacing `-` with `_`, and accepting numeric resource ids. The * `android_res`/`android_asset` and drawable/raw lookups follow expo-asset's * `ResourceAsset.kt`. `content://`, `asset://`, `res://` and `data:` URIs are * supported for parity with RN's Fresco pipeline. All decodes are downsampled diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt index b4680004..0bac0347 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt @@ -7,6 +7,7 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Build import android.text.Spannable import android.text.Spanned @@ -22,6 +23,7 @@ import com.swmansion.enriched.markdown.EnrichedMarkdownText import com.swmansion.enriched.markdown.styles.StyleConfig import com.swmansion.enriched.markdown.utils.text.ImageCache import com.swmansion.enriched.markdown.utils.text.ImageDownloader +import com.swmansion.enriched.markdown.utils.text.LocalImageLoader import java.lang.ref.WeakReference import java.util.concurrent.Executors import android.text.style.ImageSpan as AndroidImageSpan @@ -104,7 +106,8 @@ class ImageSpan( } private fun loadImage() { - if (imageUrl.startsWith("http")) { + val scheme = Uri.parse(imageUrl).scheme?.lowercase() + if (scheme == "http" || scheme == "https") { ImageDownloader.download(context, imageUrl, requestHeaders) { bitmap -> if (bitmap != null) { sourceDrawable = bitmap.toDrawable(context.resources) @@ -112,17 +115,12 @@ class ImageSpan( } } } else { - val path = imageUrl.removePrefix("file://") - try { - val cached = ImageCache.getOriginal(imageUrl) - val bitmap = cached ?: ImageDownloader.decodeFileDownsampled(context, path) - if (bitmap != null) { - if (cached == null) ImageCache.putOriginal(imageUrl, bitmap) - sourceDrawable = bitmap.toDrawable(context.resources) - wrapAndAssignDrawable() - } - } catch (e: Exception) { - Log.w(TAG, "Failed to load local image: $path", e) + val cached = ImageCache.getOriginal(imageUrl) + val bitmap = cached ?: LocalImageLoader.load(context, imageUrl) + if (bitmap != null) { + if (cached == null) ImageCache.putOriginal(imageUrl, bitmap) + sourceDrawable = bitmap.toDrawable(context.resources) + wrapAndAssignDrawable() } } } @@ -467,7 +465,6 @@ class ImageSpan( } companion object { - private const val TAG = "ImageSpan" private val transparentDrawable by lazy { Color.TRANSPARENT.toDrawable() } private val cacheExecutor = Executors.newSingleThreadExecutor() } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt index ad644222..7a2071e4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt @@ -121,6 +121,11 @@ object ImageDownloader { } } + fun decodeBytesDownsampled( + context: Context, + bytes: ByteArray, + ): Bitmap? = decodeDownsampled(bytes, context.resources.displayMetrics.widthPixels) + fun decodeFileDownsampled( context: Context, path: String, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt new file mode 100644 index 00000000..133b36f5 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt @@ -0,0 +1,158 @@ +package com.swmansion.enriched.markdown.utils.text + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Base64 +import android.util.Log +import java.io.FileNotFoundException +import java.io.InputStream + +/** + * Loads markdown images from non-network sources (issue #377). + * + * React Native's asset system produces different URI shapes for the same + * `require('./img.png')` depending on environment, and none of them are plain + * file paths in a bundled app: + * - Metro dev server: `http://host:8081/assets/...` (handled by [ImageDownloader]) + * - Release APK: a bare drawable resource name, e.g. `src_assets_logo` + * (RN's `AssetSourceResolver.resourceIdentifierWithoutScale`) + * - Sideloaded JS bundle: `file:///.../drawable-xhdpi/src_assets_logo.png` + * - expo-updates embedded assets: `file:///android_res/drawable/.png` + * - expo-asset downloads: percent-encoded `file://` paths in the cache dir + * + * Resolution mirrors RN core: `ImageSource.computeUri` treats a scheme-less + * source as a resource name, and `ResourceDrawableIdHelper` normalizes names + * by lowercasing, replacing `-` with `_`, and accepting numeric resource ids. The + * `android_res`/`android_asset` and drawable/raw lookups follow expo-asset's + * `ResourceAsset.kt`. `content://`, `asset://`, `res://` and `data:` URIs are + * supported for parity with RN's Fresco pipeline. All decodes are downsampled + * to screen width like the network path. + */ +object LocalImageLoader { + private const val TAG = "LocalImageLoader" + private const val ASSET_PATH_PREFIX = "/android_asset/" + private const val RES_PATH_PREFIX = "/android_res/" + private const val BASE64_MARKER = "base64," + + fun load( + context: Context, + source: String, + ): Bitmap? = + try { + val uri = Uri.parse(source) + when (uri.scheme?.lowercase()) { + null -> { + if (source.startsWith('/')) { + ImageDownloader.decodeFileDownsampled(context, source) + } else { + decodeResourceByName(context, source) + } + } + + "file" -> { + loadFileUri(context, uri) + } + + "asset" -> { + decodeStream(context) { context.assets.open(schemelessPath(uri)) } + } + + "content" -> { + decodeStream(context) { + context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException(source) + } + } + + "res" -> { + decodeResourceByName(context, schemelessPath(uri)) + } + + "data" -> { + decodeDataUri(context, source) + } + + else -> { + Log.w(TAG, "Unsupported image URI scheme: $source") + null + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to load local image: $source", e) + null + } + + private fun loadFileUri( + context: Context, + uri: Uri, + ): Bitmap? { + val path = uri.path ?: return null + return when { + path.startsWith(ASSET_PATH_PREFIX) -> { + decodeStream(context) { context.assets.open(path.removePrefix(ASSET_PATH_PREFIX)) } + } + + path.startsWith(RES_PATH_PREFIX) -> { + decodeAndroidRes(context, uri) + } + + else -> { + ImageDownloader.decodeFileDownsampled(context, path) + } + } + } + + /** + * Resolves `file:///android_res/[-qualifier]/[.]` by stripping + * density qualifiers and the file extension, then looking the resource up by name. + */ + private fun decodeAndroidRes( + context: Context, + uri: Uri, + ): Bitmap? { + val segments = uri.pathSegments + if (segments.size < 3) return null + val directory = segments[1].substringBefore('-') + val name = segments[2].substringBeforeLast('.') + val resId = context.resources.getIdentifier(name, directory, context.packageName) + if (resId == 0) { + Log.w(TAG, "No $directory resource named: $name") + return null + } + return decodeStream(context) { context.resources.openRawResource(resId) } + } + + private fun decodeResourceByName( + context: Context, + name: String, + ): Bitmap? { + if (name.isEmpty()) return null + val normalized = name.lowercase().replace('-', '_') + val resId = + normalized.toIntOrNull() + ?: context.resources.getIdentifier(normalized, "drawable", context.packageName).takeIf { it != 0 } + ?: context.resources.getIdentifier(normalized, "raw", context.packageName) + if (resId == 0) { + Log.w(TAG, "No drawable or raw resource named: $normalized") + return null + } + return decodeStream(context) { context.resources.openRawResource(resId) } + } + + private fun decodeDataUri( + context: Context, + source: String, + ): Bitmap? { + val marker = source.indexOf(BASE64_MARKER) + if (marker == -1) return null + val bytes = Base64.decode(source.substring(marker + BASE64_MARKER.length), Base64.DEFAULT) + return ImageDownloader.decodeBytesDownsampled(context, bytes) + } + + private inline fun decodeStream( + context: Context, + open: () -> InputStream, + ): Bitmap? = open().use { ImageDownloader.decodeBytesDownsampled(context, it.readBytes()) } + + private fun schemelessPath(uri: Uri): String = (uri.host.orEmpty() + uri.path.orEmpty()).trimStart('/') +} From 7c672070ecba0d8c99147adc552f2e34a1e06751 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 24 Jul 2026 18:13:52 +0200 Subject: [PATCH 5/5] fix: address cr --- .../enriched/markdown/utils/text/LocalImageLoader.kt | 3 +++ .../enriched/markdown/utils/text/LocalImageLoader.kt | 3 +++ .../ios/utils/ENRMImageDownloader.m | 5 +++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt index 133b36f5..1ad7693b 100644 --- a/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt +++ b/packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt @@ -77,6 +77,9 @@ object LocalImageLoader { null } } + } catch (_: OutOfMemoryError) { + Log.e(TAG, "OOM loading local image: $source") + null } catch (e: Exception) { Log.w(TAG, "Failed to load local image: $source", e) null diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt index 133b36f5..1ad7693b 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt @@ -77,6 +77,9 @@ object LocalImageLoader { null } } + } catch (_: OutOfMemoryError) { + Log.e(TAG, "OOM loading local image: $source") + null } catch (e: Exception) { Log.w(TAG, "Failed to load local image: $source", e) null diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m index e9b57cc3..97415420 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMImageDownloader.m @@ -75,7 +75,8 @@ - (void)downloadURL:(NSString *)url return; } - NSString *cacheKey = ENRMImageCacheKey(url, headers); + BOOL isLocal = ENRMIsLocalImageURL(url); + NSString *cacheKey = isLocal ? url : ENRMImageCacheKey(url, headers); RCTUIImage *cached = [[ENRMImageAttachment originalImageCache] objectForKey:cacheKey]; if (cached) { @@ -83,7 +84,7 @@ - (void)downloadURL:(NSString *)url return; } - if (ENRMIsLocalImageURL(url)) { + if (isLocal) { RCTUIImage *local = ENRMLoadLocalImage(url); if (local) { [[ENRMImageAttachment originalImageCache] setObject:local forKey:cacheKey cost:ENRMImageByteCost(local)];