diff --git a/README.md b/README.md index 10d5b10..258aa0f 100755 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ ImageEditor.cropImage(uri, cropData).then((result) => { | `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` | +| `headers`
_(optional)_ | `object \| Headers` | An object or [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) interface representing the HTTP headers to send along with the request for a remote image. | ### `result: CropResult` diff --git a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt index ff78c15..cd51dbc 100644 --- a/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt +++ b/android/src/main/java/com/reactnativecommunity/imageeditor/ImageEditorModuleImpl.kt @@ -28,6 +28,7 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.WritableMap import com.facebook.react.common.ReactConstants import java.io.ByteArrayInputStream @@ -99,6 +100,10 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { * is passed to this is the file:// URI of the new image */ fun cropImage(uri: String?, options: ReadableMap, promise: Promise) { + val headers = + if (options.hasKey("headers") && options.getType("headers") == ReadableType.Map) + options.getMap("headers")?.toHashMap() + else null 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 @@ -152,10 +157,11 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { width, height, targetWidth, - targetHeight + targetHeight, + headers ) } else { - cropTask(outOptions, uri, x, y, width, height) + cropTask(outOptions, uri, x, y, width, height, headers) } if (cropped == null) { throw IOException("Cannot decode bitmap: $uri") @@ -189,9 +195,10 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { x: Int, y: Int, width: Int, - height: Int + height: Int, + headers: HashMap? ): Bitmap? { - return openBitmapInputStream(uri)?.use { + return openBitmapInputStream(uri, headers)?.use { // Efficiently crops image without loading full resolution into memory // https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html val decoder = @@ -251,6 +258,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { rectHeight: Int, outputWidth: Int, outputHeight: Int, + headers: HashMap? ): Bitmap? { Assertions.assertNotNull(outOptions) @@ -262,7 +270,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { // Where would the crop rect end up within the scaled bitmap? val bitmap = - openBitmapInputStream(uri)?.use { + openBitmapInputStream(uri, headers)?.use { // This can use significantly less memory than decoding the full-resolution bitmap BitmapFactory.decodeStream(it, null, outOptions) } ?: return null @@ -318,7 +326,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { return Bitmap.createBitmap(bitmap, cropX, cropY, cropWidth, cropHeight, scaleMatrix, filter) } - private fun openBitmapInputStream(uri: String): InputStream? { + private fun openBitmapInputStream(uri: String, headers: HashMap?): InputStream? { return if (uri.startsWith("data:")) { val src = uri.substring(uri.indexOf(",") + 1) ByteArrayInputStream(Base64.decode(src, Base64.DEFAULT)) @@ -326,6 +334,11 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) { reactContext.contentResolver.openInputStream(Uri.parse(uri)) } else { val connection = URL(uri).openConnection() + headers?.forEach { (key, value) -> + if (value is String) { + connection.setRequestProperty(key, value) + } + } connection.getInputStream() } } diff --git a/ios/RNCImageEditor.mm b/ios/RNCImageEditor.mm index 440ea6b..75d4971 100644 --- a/ios/RNCImageEditor.mm +++ b/ios/RNCImageEditor.mm @@ -36,6 +36,7 @@ CGFloat quality; NSString *format; BOOL includeBase64; + NSDictionary *headers; }; @implementation RNCImageEditor @@ -54,6 +55,7 @@ - (Params)adaptParamsWithFormat:(id)format displayHeight:(id)displayHeight quality:(id)quality includeBase64:(id)includeBase64 + headers:(id)headers { return Params{ .offset = {[RCTConvert double:offsetX], [RCTConvert double:offsetY]}, @@ -62,7 +64,8 @@ - (Params)adaptParamsWithFormat:(id)format .resizeMode = [RCTConvert RCTResizeMode:resizeMode ?: @(DEFAULT_RESIZE_MODE)], .quality = [RCTConvert CGFloat:quality], .format = [RCTConvert NSString:format], - .includeBase64 = [RCTConvert BOOL:includeBase64] + .includeBase64 = [RCTConvert BOOL:includeBase64], + .headers = [RCTConvert NSDictionary:RCTNilIfNull(headers)] }; } @@ -83,7 +86,6 @@ - (void) cropImage:(NSString *)uri resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - NSURLRequest *imageRequest = [NSURLRequest requestWithURL:[NSURL URLWithString: uri]]; auto params = [self adaptParamsWithFormat:data.format() width:@(data.size().width()) height:@(data.size().height()) @@ -93,9 +95,10 @@ - (void) cropImage:(NSString *)uri 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) - includeBase64:@(data.includeBase64().has_value() ? *data.includeBase64() : NO)]; + includeBase64:@(data.includeBase64().has_value() ? *data.includeBase64() : NO) + headers: data.headers()]; #else -RCT_EXPORT_METHOD(cropImage:(NSURLRequest *)imageRequest +RCT_EXPORT_METHOD(cropImage:(NSString *)uri cropData:(NSDictionary *)cropData resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -110,9 +113,16 @@ - (void) cropImage:(NSString *)uri displayHeight:cropData[@"displaySize"] ? cropData[@"displaySize"][@"height"] : @(DEFAULT_DISPLAY_SIZE) quality:cropData[@"quality"] ? cropData[@"quality"] : @(DEFAULT_COMPRESSION_QUALITY) includeBase64:cropData[@"includeBase64"] - ]; + headers:cropData[@"headers"]]; #endif + NSMutableURLRequest *imageRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString: uri]]; + [params.headers enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + if (value) { + [imageRequest addValue:[RCTConvert NSString:value] forHTTPHeaderField:key]; + } + }]; + NSURL *url = [imageRequest URL]; NSString *urlPath = [url path]; NSString *extension = [urlPath pathExtension]; diff --git a/src/NativeRNCImageEditor.ts b/src/NativeRNCImageEditor.ts index c3c9a07..8d3d2db 100644 --- a/src/NativeRNCImageEditor.ts +++ b/src/NativeRNCImageEditor.ts @@ -54,6 +54,13 @@ export interface Spec extends TurboModule { * (Optional) Indicates if Base64 formatted picture data should also be included in the result. */ includeBase64?: boolean; + + /** + * (Optional) An object representing the HTTP headers to send along with the request for a remote image. + */ + headers?: { + [key: string]: string; + }; } ): Promise<{ /** diff --git a/src/index.ts b/src/index.ts index acdafd3..402e936 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,17 @@ const RNCImageEditor: Spec = NativeRNCImageEditor type CropResultWithoutBase64 = Omit; type ImageCropDataWithoutBase64 = Omit; +function toHeadersObject( + headers: ImageCropData['headers'] +): Record | undefined { + return headers instanceof Headers + ? Object.fromEntries( + // @ts-expect-error: Headers.entries isn't added yet in TS but exists in Runtime + headers.entries() + ) + : headers; +} + class ImageEditor { /** * Crop the image specified by the URI param. If URI points to a remote @@ -56,7 +67,10 @@ class ImageEditor { ): Promise; static cropImage(uri: string, cropData: ImageCropData): Promise { - return RNCImageEditor.cropImage(uri, cropData) as Promise; + return RNCImageEditor.cropImage(uri, { + ...cropData, + headers: toHeadersObject(cropData.headers), + }) as Promise; } } diff --git a/src/index.web.ts b/src/index.web.ts index 960233f..aba5ad9 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -1,14 +1,16 @@ import type { ImageCropData, CropResult } from './types.ts'; +const ERROR_PREFIX = 'ImageEditor: '; + function drawImage( - img: HTMLImageElement, + img: HTMLImageElement | ImageBitmap, { offset, size, displaySize }: ImageCropData ): HTMLCanvasElement { const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { - throw new Error('Failed to get canvas context'); + throw new Error(ERROR_PREFIX + 'Failed to get canvas context'); } const sx = offset.x, @@ -28,7 +30,30 @@ function drawImage( return canvas; } -function fetchImage(imgSrc: string): Promise { +function fetchImage( + imgSrc: string, + headers: ImageCropData['headers'] +): Promise { + if (headers) { + return fetch(imgSrc, { + method: 'GET', + headers: new Headers(headers), + }) + .then((response) => { + if (!response.ok) { + throw new Error( + ERROR_PREFIX + + 'Failed to fetch the image: ' + + imgSrc + + '. Request failed with status: ' + + response.status + ); + } + return response.blob(); + }) + .then((blob) => createImageBitmap(blob)); + } + return new Promise((resolve, reject) => { const onceOptions = { once: true }; const img = new Image(); @@ -58,51 +83,53 @@ class ImageEditor { /** * Returns a promise that resolves with the base64 encoded string of the cropped image */ - return fetchImage(imgSrc).then(function onfulfilledImgToCanvas(image) { - const ext = cropData.format ?? 'jpeg'; - const type = `image/${ext}`; - const quality = cropData.quality ?? DEFAULT_COMPRESSION_QUALITY; - const canvas = drawImage(image, cropData); - - return new Promise(function onfulfilledCanvasToBlob( - resolve - ) { - canvas.toBlob(resolve, type, quality); - }).then((blob) => { - if (!blob) { - throw new Error('Image cannot be created from canvas'); - } - - let _path: string, _uri: string; - - 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() { - if (!_path) { - _path = URL.createObjectURL(blob); - } - return _path; - }, - get uri() { - return result.base64 as string; - }, - get base64() { - if (!_uri) { - _uri = canvas.toDataURL(type, quality); - } - return _uri.split(',')[1]; - // ^^^ remove `data:image/xxx;base64,` prefix (to align with iOS/Android platform behavior) - }, - }; - - return result; - }); - }); + return fetchImage(imgSrc, cropData.headers).then( + function onfulfilledImgToCanvas(image) { + const ext = cropData.format ?? 'jpeg'; + const type = `image/${ext}`; + const quality = cropData.quality ?? DEFAULT_COMPRESSION_QUALITY; + const canvas = drawImage(image, cropData); + + return new Promise(function onfulfilledCanvasToBlob( + resolve + ) { + canvas.toBlob(resolve, type, quality); + }).then((blob) => { + if (!blob) { + throw new Error('Image cannot be created from canvas'); + } + + let _path: string, _uri: string; + + 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() { + if (!_path) { + _path = URL.createObjectURL(blob); + } + return _path; + }, + get uri() { + return result.base64 as string; + }, + get base64() { + if (!_uri) { + _uri = canvas.toDataURL(type, quality); + } + 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 40f1f05..53d4583 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,8 @@ import type { Spec } from './NativeRNCImageEditor.ts'; type ImageCropDataFromSpec = Parameters[1]; export interface ImageCropData - extends Omit { + extends Omit { + headers?: Record | Headers; format?: 'png' | 'jpeg' | 'webp'; resizeMode?: 'contain' | 'cover' | 'stretch' | 'center'; // ^^^ codegen doesn't support union types yet