diff --git a/README.md b/README.md index e5f8d15..b0ac36e 100755 --- a/README.md +++ b/README.md @@ -36,47 +36,38 @@ import ImageEditor from '@react-native-community/image-editor'; Crop the image specified by the URI param. If URI points to a remote image, it will be downloaded automatically. If the image cannot be loaded/downloaded, the promise will be rejected. -If the cropping process is successful, the resultant cropped image will be stored in the cache path, and the URI returned in the promise will point to the image in the cache path. Remember to delete the cropped image from the cache path when you are done with it. +If the cropping process is successful, the resultant cropped image will be stored in the cache path, and the [`CropResult`](#result-cropresult) returned in the promise will point to the image in the cache path. ⚠️ Remember to delete the cropped image from the cache path when you are done with it. ```ts -ImageEditor.cropImage(uri, cropData).then( - ({ - uri, // the path to the image file (example: 'file:///data/user/0/.../image.jpg') - path, // the URI of the image (example: '/data/user/0/.../image.jpg') - name, // the name of the image file. (example: 'image.jpg') - width, // the width of the image in pixels - height, // height of the image in pixels - size, // the size of the image in bytes - }) => { - console.log('Cropped image uri:', uri); - // WEB has different response: - // - `uri` is the base64 string (example `data:image/jpeg;base64,/4AAQ...AQABAA`) - // - `path` is the blob URL (example `blob:https://example.com/43ff7a16...e46b1`) - } -); +ImageEditor.cropImage(uri, cropData).then((result) => { + console.log('Cropped image uri:', result.uri); +}); ``` ### `cropData: ImageCropData` -| Property | Required | Description | -| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `offset` | Yes | The top-left corner of the cropped image, specified in the original image's coordinate space | -| `size` | Yes | Size (dimensions) of the cropped image | -| `displaySize` | No | Size to which you want to scale the cropped image | -| `resizeMode` | No | Resizing mode to use when scaling the image (iOS only, Android resize mode is always `'cover'`, Web - no support) **Default value**: `'cover'` | -| `quality` | No | The quality of the resulting image, expressed as a value from `0.0` to `1.0`.
The value `0.0` represents the maximum compression (or lowest quality) while the value `1.0` represents the least compression (or best quality).
iOS supports only `JPEG` format, while Android/Web supports both `JPEG`, `WEBP` and `PNG` formats.
**Default value**: `0.9` | -| `format` | No | The format of the resulting image, possible values are `jpeg`, `png`, `webp`.
**Default value**: based on the provided image; if value determination is not possible, `jpeg` will be used as a fallback.
`webp` isn't supported by iOS. | - -```ts -cropData: ImageCropData = { - offset: { x: number, y: number }, - size: { width: number, height: number }, - displaySize: { width: number, height: number }, - resizeMode: 'contain' | 'cover' | 'stretch', - quality: number, // 0...1 - format: 'jpeg' | 'png' | 'webp', -}; -``` +| Name | Type | Description | +| ------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `offset` | `{ x: number, y: number }` | The top-left corner of the cropped image, specified in the original image's coordinate space | +| `size` | `{ width: number, height: number }` | Size (dimensions) of the cropped image | +| `displaySize`
_(optional)_ | `{ width: number, height: number }` | Size to which you want to scale the cropped image | +| `resizeMode`
_(optional)_ | `'contain' \| 'cover' \| 'stretch' \| 'center'` | Resizing mode to use when scaling the image (iOS only, Android resize mode is always `'cover'`, Web - no support)
**Default value**: `'cover'` | +| `quality`
_(optional)_ | `number` | A value in range `0.0` - `1.0` specifying compression level of the result image. `1` means no compression (highest quality) and `0` the highest compression (lowest quality)
**Default value**: `0.9` | +| `format`
_(optional)_ | `'jpeg' \| 'png' \| 'webp'` | The format of the resulting image.
**Default value**: based on the provided image;
if value determination is not possible, `'jpeg'` will be used as a fallback.
`'webp'` isn't supported by iOS. | +| `includeBase64`
_(optional)_ | `boolean` | Indicates if Base64 formatted picture data should also be included in the [`CropResult`](#result-cropresult).
**Default value**: `false` | + +### `result: CropResult` + +| Name | Type | Description | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uri` | `string` | The path to the image file (example: `'file:///data/user/0/.../image.jpg'`)
**WEB:** `uri` is the data URI string (example `'data:image/jpeg;base64,/4AAQ...AQABAA'`) | +| `path` | `string` | The URI of the image (example: `'/data/user/0/.../image.jpg'`)
**WEB:** `path` is the blob URL (example `'blob:https://example.com/43ff7a16...e46b1'`) | +| `name` | `string` | The name of the image file. (example: `'image.jpg'`) | +| `width` | `number` | The width of the image in pixels | +| `height` | `number` | Height of the image in pixels | +| `size` | `number` | The size of the image in bytes | +| `type` | `string` | The MIME type of the image (`'image/jpeg'`, `'image/png'`, `'image/webp'`) | +| `base64`
_(optional)_ | `string` | The base64-encoded image data example: `'/9j/4AAQSkZJRgABAQAAAQABAAD'`
if you need data URI as the `source` for an `Image` element for example, you can use `data:${type};base64,${base64}` | For more advanced usage check our [example app](/example/src/App.tsx). diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt index 4ffc191..ff78c15 100644 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt @@ -19,7 +19,7 @@ import android.net.Uri import android.os.Build import android.provider.MediaStore import android.text.TextUtils -import android.util.Base64 as AndroidUtilBase64 +import android.util.Base64 import androidx.exifinterface.media.ExifInterface import com.facebook.common.logging.FLog import com.facebook.infer.annotation.Assertions @@ -32,11 +32,11 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.common.ReactConstants import java.io.ByteArrayInputStream import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.net.URL -import java.util.Base64 import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -102,6 +102,8 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { val format = if (options.hasKey("format")) options.getString("format") else null val offset = if (options.hasKey("offset")) options.getMap("offset") else null val size = if (options.hasKey("size")) options.getMap("size") else null + val includeBase64 = + if (options.hasKey("includeBase64")) options.getBoolean("includeBase64") else false val quality = if (options.hasKey("quality")) (options.getDouble("quality") * 100).toInt() else 90 if ( @@ -164,7 +166,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { if (mimeType == MimeType.JPEG) { copyExif(reactContext, Uri.parse(uri), tempFile) } - promise.resolve(getResultMap(tempFile, cropped)) + promise.resolve(getResultMap(tempFile, cropped, mimeType, includeBase64)) } catch (e: Exception) { promise.reject(e) } @@ -319,11 +321,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { private fun openBitmapInputStream(uri: String): InputStream? { return if (uri.startsWith("data:")) { val src = uri.substring(uri.indexOf(",") + 1) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ByteArrayInputStream(Base64.getMimeDecoder().decode(src)) - } else { - ByteArrayInputStream(AndroidUtilBase64.decode(src, AndroidUtilBase64.DEFAULT)) - } + ByteArrayInputStream(Base64.decode(src, Base64.DEFAULT)) } else if (isLocalUri(uri)) { reactContext.contentResolver.openInputStream(Uri.parse(uri)) } else { @@ -439,7 +437,12 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { ) // Utils - private fun getResultMap(resizedImage: File, image: Bitmap): WritableMap { + private fun getResultMap( + resizedImage: File, + image: Bitmap, + mimeType: String, + includeBase64: Boolean + ): WritableMap { val response = Arguments.createMap() response.putString("path", resizedImage.absolutePath) response.putString("uri", Uri.fromFile(resizedImage).toString()) @@ -447,9 +450,23 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { response.putInt("size", resizedImage.length().toInt()) response.putInt("width", image.width) response.putInt("height", image.height) + response.putString("type", mimeType) + + if (includeBase64) { + response.putString("base64", getBase64String(resizedImage)) + } + return response } + private fun getBase64String(file: File): String { + val inputStream = FileInputStream(file) + val buffer = ByteArray(file.length().toInt()) + inputStream.read(buffer) + inputStream.close() + return Base64.encodeToString(buffer, Base64.NO_WRAP) + } + private fun getMimeType(outOptions: BitmapFactory.Options, format: String?): String { val mimeType = when (format) { diff --git a/ios/RNCImageEditor.mm b/ios/RNCImageEditor.mm index caf59e7..440ea6b 100644 --- a/ios/RNCImageEditor.mm +++ b/ios/RNCImageEditor.mm @@ -35,6 +35,7 @@ RCTResizeMode resizeMode; CGFloat quality; NSString *format; + BOOL includeBase64; }; @implementation RNCImageEditor @@ -52,6 +53,7 @@ - (Params)adaptParamsWithFormat:(id)format displayWidth:(id)displayWidth displayHeight:(id)displayHeight quality:(id)quality + includeBase64:(id)includeBase64 { return Params{ .offset = {[RCTConvert double:offsetX], [RCTConvert double:offsetY]}, @@ -59,7 +61,8 @@ - (Params)adaptParamsWithFormat:(id)format .displaySize = {[RCTConvert double:displayWidth], [RCTConvert double:displayHeight]}, .resizeMode = [RCTConvert RCTResizeMode:resizeMode ?: @(DEFAULT_RESIZE_MODE)], .quality = [RCTConvert CGFloat:quality], - .format = [RCTConvert NSString:format] + .format = [RCTConvert NSString:format], + .includeBase64 = [RCTConvert BOOL:includeBase64] }; } @@ -89,7 +92,8 @@ - (void) cropImage:(NSString *)uri resizeMode:data.resizeMode() displayWidth:@(data.displaySize().has_value() ? data.displaySize()->width() : DEFAULT_DISPLAY_SIZE) displayHeight:@(data.displaySize().has_value() ? data.displaySize()->height() : DEFAULT_DISPLAY_SIZE) - quality:@(data.quality().has_value() ? *data.quality() : DEFAULT_COMPRESSION_QUALITY)]; + quality:@(data.quality().has_value() ? *data.quality() : DEFAULT_COMPRESSION_QUALITY) + includeBase64:@(data.includeBase64().has_value() ? *data.includeBase64() : NO)]; #else RCT_EXPORT_METHOD(cropImage:(NSURLRequest *)imageRequest cropData:(NSDictionary *)cropData @@ -104,7 +108,9 @@ - (void) cropImage:(NSString *)uri resizeMode:cropData[@"resizeMode"] displayWidth:cropData[@"displaySize"] ? cropData[@"displaySize"][@"width"] : @(DEFAULT_DISPLAY_SIZE) displayHeight:cropData[@"displaySize"] ? cropData[@"displaySize"][@"height"] : @(DEFAULT_DISPLAY_SIZE) - quality:cropData[@"quality"] ? cropData[@"quality"] : @(DEFAULT_COMPRESSION_QUALITY)]; + quality:cropData[@"quality"] ? cropData[@"quality"] : @(DEFAULT_COMPRESSION_QUALITY) + includeBase64:cropData[@"includeBase64"] + ]; #endif NSURL *url = [imageRequest URL]; @@ -139,14 +145,15 @@ - (void) cropImage:(NSString *)uri } // Store image + NSString *type = @"image/jpeg"; NSString *path = NULL; NSData *imageData = NULL; if([extension isEqualToString:@"png"]){ + type = @"image/png"; imageData = UIImagePNGRepresentation(croppedImage); path = [RNCFileSystem generatePathInDirectory:[[RNCFileSystem cacheDirectoryPath] stringByAppendingPathComponent:@"ReactNative_cropped_image_"] withExtension:@".png"]; - } - else{ + } else{ imageData = UIImageJPEGRepresentation(croppedImage, params.quality); path = [RNCFileSystem generatePathInDirectory:[[RNCFileSystem cacheDirectoryPath] stringByAppendingPathComponent:@"ReactNative_cropped_image_"] withExtension:@".jpg"]; } @@ -164,14 +171,18 @@ - (void) cropImage:(NSString *)uri NSError *attributesError = nil; NSDictionary *fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:&attributesError]; NSNumber *fileSize = fileAttributes == nil ? 0 : [fileAttributes objectForKey:NSFileSize]; - NSDictionary *response = @{ - @"path": path, - @"uri": uri, - @"name": filename, - @"size": fileSize ?: @(0), - @"width": @(croppedImage.size.width), - @"height": @(croppedImage.size.height), - }; + + NSMutableDictionary *response = [[NSMutableDictionary alloc] init]; + response[@"path"] = path; + response[@"uri"] = uri; + response[@"name"] = filename; + response[@"type"] = type; + response[@"size"] = fileSize ?: @(0); + response[@"width"] = @(croppedImage.size.width); + response[@"height"] = @(croppedImage.size.height); + if (params.includeBase64) { + response[@"base64"] = [imageData base64EncodedStringWithOptions:0]; + } resolve(response); }]; diff --git a/package.json b/package.json index fc91e97..4497987 100755 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!**/__typetests__", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", diff --git a/src/NativeRNCImageEditor.ts b/src/NativeRNCImageEditor.ts index f911a0b..c3c9a07 100644 --- a/src/NativeRNCImageEditor.ts +++ b/src/NativeRNCImageEditor.ts @@ -49,6 +49,11 @@ export interface Spec extends TurboModule { * (Optional) The format of the resulting image. Default auto-detection based on given image */ format?: string; + + /** + * (Optional) Indicates if Base64 formatted picture data should also be included in the result. + */ + includeBase64?: boolean; } ): Promise<{ /** @@ -75,6 +80,16 @@ export interface Spec extends TurboModule { * The size of the image in bytes */ size: Int32; + + /** + * MIME type of the image (example: 'image/jpeg') + */ + type: string; + + /** + * The base64 string of the image if the `base64` param is true + */ + base64?: string; }>; } diff --git a/src/__typetests__/index.ts b/src/__typetests__/index.ts new file mode 100644 index 0000000..98fbc86 --- /dev/null +++ b/src/__typetests__/index.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +import ImageEditorNative from '../index.ts'; + +const requiredParams = { + size: { width: 100, height: 100 }, + offset: { x: 0, y: 0 }, +}; + +ImageEditorNative.cropImage('', { + ...requiredParams, + includeBase64: true, + // ^^^: if `true` then result has `base64` property as string +}).then((e) => { + const a: string = e.base64; + // @ts-expect-error - base64 is a string + const b: number = e.base64; +}); + +ImageEditorNative.cropImage('', { + ...requiredParams, + includeBase64: false, + // ^^^: if `false` then result doesn't have `base64` property +}).then((e) => { + // @ts-expect-error - base64 doesn't exist + const a: string = e.base64; +}); + +ImageEditorNative.cropImage('', { + ...requiredParams, + // includeBase64: false, + // ^^^: if `undefined` then result doesn't have `base64` property +}).then((e) => { + // @ts-expect-error - base64 doesn't exist + const a: string = e.base64; +}); diff --git a/src/index.ts b/src/index.ts index bbb918f..acdafd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,9 @@ const RNCImageEditor: Spec = NativeRNCImageEditor }, }); +type CropResultWithoutBase64 = Omit; +type ImageCropDataWithoutBase64 = Omit; + class ImageEditor { /** * Crop the image specified by the URI param. If URI points to a remote @@ -37,8 +40,23 @@ class ImageEditor { * will point to the image in the cache path. Remember to delete the * cropped image from the cache path when you are done with it. */ - static cropImage(uri: string, cropData: ImageCropData): CropResult { - return RNCImageEditor.cropImage(uri, cropData); + + // TS overload for better `base64` type inference (see: `src/__typetests__/index.ts`) + static cropImage( + uri: string, + cropData: ImageCropDataWithoutBase64 + ): Promise; + static cropImage( + uri: string, + cropData: ImageCropDataWithoutBase64 & { includeBase64: false } + ): Promise; + static cropImage( + uri: string, + cropData: ImageCropDataWithoutBase64 & { includeBase64: true } + ): Promise; + + static cropImage(uri: string, cropData: ImageCropData): Promise { + return RNCImageEditor.cropImage(uri, cropData) as Promise; } } diff --git a/src/index.web.ts b/src/index.web.ts index c74360d..960233f 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -51,7 +51,10 @@ function fetchImage(imgSrc: string): Promise { const DEFAULT_COMPRESSION_QUALITY = 0.9; class ImageEditor { - static cropImage(imgSrc: string, cropData: ImageCropData): CropResult { + static cropImage( + imgSrc: string, + cropData: ImageCropData + ): Promise { /** * Returns a promise that resolves with the base64 encoded string of the cropped image */ @@ -72,10 +75,11 @@ class ImageEditor { let _path: string, _uri: string; - return { + const result: CropResult = { width: canvas.width, height: canvas.height, name: 'ReactNative_cropped_image.' + ext, + type: ('image/' + ext) as CropResult['type'], size: blob.size, // Lazy getters to avoid unnecessary memory usage get path() { @@ -85,12 +89,18 @@ class ImageEditor { return _path; }, get uri() { + return result.base64 as string; + }, + get base64() { if (!_uri) { _uri = canvas.toDataURL(type, quality); } - return _uri; + return _uri.split(',')[1]; + // ^^^ remove `data:image/xxx;base64,` prefix (to align with iOS/Android platform behavior) }, }; + + return result; }); }); } diff --git a/src/types.ts b/src/types.ts index baac5fe..40f1f05 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,4 +10,13 @@ export interface ImageCropData // so to provide more type safety we override the type here } -export type CropResult = ReturnType; +export interface CropResult + extends Omit, 'type'> { + type: 'image/jpeg' | 'image/png' | 'image/webp'; + // ^^^ codegen doesn't support union types yet +} + +// Utils +type AsyncReturnType = T extends (...args: any[]) => Promise + ? R + : never;