build: compile release APK and update dependencies (expo-image-manipulator/loader)

This commit is contained in:
2026-05-31 14:24:26 +02:00
parent 69637c4203
commit 2b5a259fe5
1260 changed files with 3804 additions and 166620 deletions
+2
View File
@@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');
+361
View File
@@ -0,0 +1,361 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
### 💡 Others
## 14.0.8 — 2025-12-05
_This version does not introduce any user-facing changes._
## 14.0.7 — 2025-09-10
### 💡 Others
- Extracted the read permission check from `FileSystemUtilities` ([#39210](https://github.com/expo/expo/pull/39210) by [@kosmydel](https://github.com/kosmydel))
## 14.0.6 — 2025-09-02
_This version does not introduce any user-facing changes._
## 14.0.5 — 2025-08-31
_This version does not introduce any user-facing changes._
## 14.0.4 — 2025-08-27
_This version does not introduce any user-facing changes._
## 14.0.3 — 2025-08-25
_This version does not introduce any user-facing changes._
## 14.0.2 — 2025-08-16
_This version does not introduce any user-facing changes._
## 14.0.1 — 2025-08-15
_This version does not introduce any user-facing changes._
## 14.0.0 — 2025-08-13
### 🎉 New features
- [iOS] Add Apple TV support. ([#38513](https://github.com/expo/expo/pull/38513) by [@douglowder](https://github.com/douglowder))
## 13.1.7 — 2025-05-08
### 🐛 Bug fixes
- Fix `ReferenceError` during export for web. ([#36703](https://github.com/expo/expo/pull/36703) by [@aleqsio](https://github.com/aleqsio))
## 13.1.6 — 2025-05-06
_This version does not introduce any user-facing changes._
## 13.1.5 — 2025-04-30
_This version does not introduce any user-facing changes._
## 13.1.4 — 2025-04-25
_This version does not introduce any user-facing changes._
## 13.1.3 — 2025-04-14
_This version does not introduce any user-facing changes._
## 13.1.2 — 2025-04-11
_This version does not introduce any user-facing changes._
## 13.1.1 — 2025-04-09
_This version does not introduce any user-facing changes._
## 13.1.0 — 2025-04-04
### 🐛 Bug fixes
- Changed `export` to `export type` for TS type declarations. ([#33446](https://github.com/expo/expo/pull/33446) by [@j-piasecki](https://github.com/j-piasecki))
- Fixed `saveAsync` not respecting the format and compression quality. ([#33690](https://github.com/expo/expo/pull/33690) by [@tsapeta](https://github.com/tsapeta))
- [Web] Overwrite class `name` property in `ImageManipulator`. ([#35806](https://github.com/expo/expo/pull/35806) by [@alimek](https://github.com/alimek))
### 💡 Others
- [Android] Started using expo modules gradle plugin. ([#34176](https://github.com/expo/expo/pull/34176) by [@lukmccall](https://github.com/lukmccall))
- [apple] Migrate remaining `expo-module.config.json` to unified platform syntax. ([#34445](https://github.com/expo/expo/pull/34445) by [@reichhartd](https://github.com/reichhartd))
- [iOS] Fix warnings which will become errors in Swift 6. ([#35288](https://github.com/expo/expo/pull/35288) by [@behenate](https://github.com/behenate))
## 13.0.6 - 2025-01-10
_This version does not introduce any user-facing changes._
## 13.0.5 — 2024-10-29
### 🎉 New features
- [Android] Added support for image refs in `ImageManipulator.manipulate` and `useImageManipulator`. ([#32398](https://github.com/expo/expo/pull/32398) by [@lukmccall](https://github.com/lukmccall))
## 13.0.4 — 2024-10-28
### 🎉 New features
- [iOS][Web] Added support for image refs in `ImageManipulator.manipulate` and `useImageManipulator`. ([#32346](https://github.com/expo/expo/pull/32346), [#32354](https://github.com/expo/expo/pull/32354) by [@tsapeta](https://github.com/tsapeta))
## 13.0.3 — 2024-10-25
### 🎉 New features
- [iOS] Added support for image refs in `ImageManipulator.manipulate` and `useImageManipulator`. ([#32346](https://github.com/expo/expo/pull/32346) by [@tsapeta](https://github.com/tsapeta))
## 13.0.2 — 2024-10-24
### 💡 Others
- Exported missing types, removed unnecessary fallback and properly registered the module on Web. ([#32302](https://github.com/expo/expo/pull/32302) by [@tsapeta](https://github.com/tsapeta))
## 13.0.1 — 2024-10-22
_This version does not introduce any user-facing changes._
## 13.0.0 — 2024-10-22
### 🛠 Breaking changes
- Bumped iOS deployment target to 15.1. ([#30840](https://github.com/expo/expo/pull/30840) by [@tsapeta](https://github.com/tsapeta))
### 🎉 New features
- [iOS][Web] Introduce a new API for image manipulation. ([#30135](https://github.com/expo/expo/pull/30135), [#30194](https://github.com/expo/expo/pull/30194) by [@tsapeta](https://github.com/tsapeta))
- [Android] Introduce a new API for image manipulation. ([#30232](https://github.com/expo/expo/pull/30232) by [@lukmccall](https://github.com/lukmccall))
### 💡 Others
- Use the `src` folder as the Metro target. ([#30079](https://github.com/expo/expo/pull/30079) by [@tsapeta](https://github.com/tsapeta))
- Prefer `UIGraphicsImageRenderer` over `UIGraphicsBeginImageContext`. ([#30211](https://github.com/expo/expo/pull/30211) by [@alanjhughes](https://github.com/alanjhughes))
- Provide image's memory footprint for better garbage collection. ([#31168](https://github.com/expo/expo/pull/31168) by [@tsapeta](https://github.com/tsapeta) & [#31784](https://github.com/expo/expo/pull/31784) by [@lukmccall](https://github.com/lukmccall))
## 12.0.5 — 2024-05-13
_This version does not introduce any user-facing changes._
## 12.0.4 — 2024-05-13
### 🐛 Bug fixes
- On `iOS` correctly handle urls coming from the users photo library. ([#28777](https://github.com/expo/expo/pull/28777) by [@alanjhughes](https://github.com/alanjhughes))
## 12.0.3 — 2024-05-01
_This version does not introduce any user-facing changes._
## 12.0.2 — 2024-04-23
_This version does not introduce any user-facing changes._
## 12.0.1 — 2024-04-22
### 🎉 New features
- Added support for converting to WEBP on Android and iOS. ([#26379](https://github.com/expo/expo/pull/26379) by [@NikitaDudin](https://github.com/NikitaDudin))
## 12.0.0 — 2024-04-18
### 💡 Others
- drop unused web `name` property. ([#27437](https://github.com/expo/expo/pull/27437) by [@EvanBacon](https://github.com/EvanBacon))
- Removed deprecated backward compatible Gradle settings. ([#28083](https://github.com/expo/expo/pull/28083) by [@kudo](https://github.com/kudo))
## 11.8.0 — 2023-12-12
### 🐛 Bug fixes
- [iOS] Fix an issue where the image is manipulated on a background thread. ([#25756](https://github.com/expo/expo/pull/25756) by [@alanjhughes](https://github.com/alanjhughes))
### 💡 Others
- [iOS] Replace legacy `FileSystem` interfaces usage with core `FileSystemUtilities`. ([#25495](https://github.com/expo/expo/pull/25495) by [@alanhughes](https://github.com/alanjhughes))
## 11.7.0 — 2023-11-14
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.4. ([#25063](https://github.com/expo/expo/pull/25063) by [@gabrieldonadel](https://github.com/gabrieldonadel))
- On `Android` bump `compileSdkVersion` and `targetSdkVersion` to `34`. ([#24708](https://github.com/expo/expo/pull/24708) by [@alanjhughes](https://github.com/alanjhughes))
### 🎉 New features
- Added `extent` action ([#25116](https://github.com/expo/expo/pull/25116) by [@LinusU](https://github.com/LinusU))
## 11.6.0 — 2023-10-17
### 🛠 Breaking changes
- Dropped support for Android SDK 21 and 22. ([#24201](https://github.com/expo/expo/pull/24201) by [@behenate](https://github.com/behenate))
## 11.5.0 — 2023-09-04
### 🎉 New features
- Added support for React Native 0.73. ([#24018](https://github.com/expo/expo/pull/24018) by [@kudo](https://github.com/kudo))
## 11.4.1 — 2023-08-02
_This version does not introduce any user-facing changes._
## 11.4.0 — 2023-07-28
_This version does not introduce any user-facing changes._
## 11.3.0 — 2023-06-21
### 🐛 Bug fixes
- Fixed Android build warnings for Gradle version 8. ([#22537](https://github.com/expo/expo/pull/22537), [#22609](https://github.com/expo/expo/pull/22609) by [@kudo](https://github.com/kudo))
## 11.2.0 — 2023-05-08
### 🐛 Bug fixes
- Handle images with an unsupported color space ([#21757](https://github.com/expo/expo/pull/21757) by [@mmmulani](https://github.com/mmmulani))
## 11.1.1 — 2023-02-09
_This version does not introduce any user-facing changes._
## 11.1.0 — 2023-02-03
### 💡 Others
- On Android bump `compileSdkVersion` and `targetSdkVersion` to `33`. ([#20721](https://github.com/expo/expo/pull/20721) by [@lukmccall](https://github.com/lukmccall))
## 11.0.0 — 2022-10-25
### 🛠 Breaking changes
- Bumped iOS deployment target to 13.0 and deprecated support for iOS 12. ([#18873](https://github.com/expo/expo/pull/18873) by [@tsapeta](https://github.com/tsapeta))
- Migrated Android codebase to use the new Expo modules API. ([#20683](https://github.com/expo/expo/pull/20683) by [@alanhughes](https://github.com/alanjhughes))
## 10.4.0 — 2022-07-07
### 💡 Others
- Migrated Expo modules definitions to the new naming convention. ([#17193](https://github.com/expo/expo/pull/17193) by [@tsapeta](https://github.com/tsapeta))
## 10.3.1 — 2022-04-20
### 🐛 Bug fixes
- Fix `base64` result of `manipulateAsync` being always `null` on iOS. ([#17122](https://github.com/expo/expo/pull/17122) by [@barthap](https://github.com/barthap))
## 10.3.0 — 2022-04-18
### 🎉 New features
- Native module on iOS is now written in Swift using the new API. ([#15277](https://github.com/expo/expo/pull/15277) by [@tsapeta](https://github.com/tsapeta))
### 🐛 Bug fixes
- Remove `data:image` part in web base64 result. ([#16191](https://github.com/expo/expo/pull/16191) by [@AllanChain](https://github.com/AllanChain))
- On iOS fix rotation causing extra image borders. ([#16669](https://github.com/expo/expo/pull/16669) by [@mnightingale](https://github.com/mnightingale))
### ⚠️ Notices
- On Android bump `compileSdkVersion` to `31`, `targetSdkVersion` to `31` and `Java` version to `11`. ([#16941](https://github.com/expo/expo/pull/16941) by [@bbarthec](https://github.com/bbarthec))
## 10.2.1 - 2022-02-01
### 🐛 Bug fixes
- Fix `Plugin with id 'maven' not found` build error from Android Gradle 7. ([#16080](https://github.com/expo/expo/pull/16080) by [@kudo](https://github.com/kudo))
## 10.2.0 — 2021-12-03
### 🐛 Bug fixes
- Fix `Tainted canvases may not be exported` CORS error on web. ([#14739](https://github.com/expo/expo/pull/14739) by [@IjzerenHein](https://github.com/IjzerenHein))
## 10.1.0 — 2021-10-01
### 🐛 Bug fixes
- Added missing dependency on `expo-image-loader`. ([#14585](https://github.com/expo/expo/pull/14585) by [@tsapeta](https://github.com/tsapeta))
## 10.0.0 — 2021-09-28
### 🛠 Breaking changes
- Dropped support for iOS 11.0 ([#14383](https://github.com/expo/expo/pull/14383) by [@cruzach](https://github.com/cruzach))
### 🎉 New features
- Support loading base64 data URIs on iOS. ([#13725](https://github.com/expo/expo/pull/13725) by [@mnightingale](https://github.com/mnightingale))
### 🐛 Bug fixes
- Fix resize action validator to allow providing just one of `width` or `height`. ([#13369](https://github.com/expo/expo/pull/13369) by [@cruzach](https://github.com/cruzach))
- Fix incorrect compression used when `0` is requested on web. ([#13728](https://github.com/expo/expo/pull/13728) by [@mnightingale](https://github.com/mnightingale))
- Fix Android `manipulateAsync` returns incorrect height (original unmanipulated height). ([#13726](https://github.com/expo/expo/pull/13726) by [@mnightingale](https://github.com/mnightingale))
- Fixed Android to use filename extension consistent with other platforms. ([#13726](https://github.com/expo/expo/pull/13726) by [@mnightingale](https://github.com/mnightingale))
- Fixed rotation direction to be clockwise on web. ([#13760](https://github.com/expo/expo/pull/13760) by [@mnightingale](https://github.com/mnightingale))
- Fixed web support for multiple actions. ([#14056](https://github.com/expo/expo/pull/14056) by [@mnightingale](https://github.com/mnightingale))
- Fix building errors from use_frameworks! in Podfile. ([#14523](https://github.com/expo/expo/pull/14523) by [@kudo](https://github.com/kudo))
### 💡 Others
- Migrated from `@unimodules/core` to `expo-modules-core`. ([#13757](https://github.com/expo/expo/pull/13757) by [@tsapeta](https://github.com/tsapeta))
- Refactored web to pass canvases to each action avoiding multiple calls to `toDataURL`. ([#14145](https://github.com/expo/expo/pull/14145) by [@mnightingale](https://github.com/mnightingale))
## 9.2.0 — 2021-06-16
### 🐛 Bug fixes
- Enable kotlin in all modules. ([#12716](https://github.com/expo/expo/pull/12716) by [@wschurman](https://github.com/wschurman))
### 💡 Others
- Migrated from `unimodules-file-system-interface` to `expo-modules-core`.
- Build Android code using Java 8 to fix Android instrumented test build error. ([#12939](https://github.com/expo/expo/pull/12939) by [@kudo](https://github.com/kudo))
- Converted Android code to Kotlin. ([#13231](https://github.com/expo/expo/pull/13231) by [@dsokal](https://github.com/dsokal))
## 9.1.0 — 2021-03-10
### 🎉 New features
- Updated Android build configuration to target Android 11 (added support for Android SDK 30). ([#11647](https://github.com/expo/expo/pull/11647) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- Fixed incorrect image cropping on Web. ([#12021](https://github.com/expo/expo/pull/12021) by [@rSkogeby](https://github.com/rskogeby))
## 9.0.0 — 2021-01-15
### 🛠 Breaking changes
- Dropped support for iOS 10.0 ([#11344](https://github.com/expo/expo/pull/11344) by [@tsapeta](https://github.com/tsapeta))
## 8.4.0 — 2020-11-17
_This version does not introduce any user-facing changes._
## 8.3.0 — 2020-08-18
_This version does not introduce any user-facing changes._
## 8.2.1 — 2020-05-29
_This version does not introduce any user-facing changes._
## 8.2.0 — 2020-05-27
### 🛠 Breaking changes
- The base64 output will no longer contain newline and special character (`\n`, `\r`) on Android. ([#7841](https://github.com/expo/expo/pull/7841) by [@jarvisluong](https://github.com/jarvisluong))
+41
View File
@@ -0,0 +1,41 @@
<p>
<a href="https://docs.expo.dev/versions/latest/sdk/imagemanipulator/">
<img
src="../../.github/resources/expo-image-manipulator.svg"
alt="expo-image-manipulator"
height="64" />
</a>
</p>
Provides functions that let you manipulation images on the local file system, eg: resize, crop.
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/imagemanipulator/)
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/imagemanipulator/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/imagemanipulator/).
# Installation in bare React Native projects
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
### Add the package to your npm dependencies
```
npx expo install expo-image-manipulator
```
### Configure for Android
No additional set up necessary.
### Configure for iOS
Run `npx pod-install` after installing the npm package.
# Contributing
Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing).
+19
View File
@@ -0,0 +1,19 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'host.exp.exponent'
version = '14.0.8'
android {
namespace "expo.modules.imagemanipulator"
defaultConfig {
versionCode 23
versionName "14.0.8"
}
}
dependencies {
api "androidx.annotation:annotation:1.0.0"
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,23 @@
package expo.modules.imagemanipulator
import android.content.Context
import java.io.File
import java.io.IOException
import java.util.*
internal object FileUtils {
@Throws(IOException::class)
fun generateRandomOutputPath(context: Context, imageFormat: ImageFormat): String {
val directory = File("${context.cacheDir}${File.separator}ImageManipulator")
ensureDirExists(directory)
return "${directory}${File.separator}${UUID.randomUUID()}${imageFormat.fileExtension}"
}
@Throws(IOException::class)
private fun ensureDirExists(dir: File): File {
if (!(dir.isDirectory || dir.mkdirs())) {
throw ImageWriteFailedException(dir.path)
}
return dir
}
}
@@ -0,0 +1,80 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable
/**
* Options provided for resize action.
*/
class ResizeOptions : Record {
@Field
val width: Int? = null
@Field
val height: Int? = null
}
/**
* Cropping rect for crop action.
*/
class CropRect : Record {
@Field
val originX: Double = 0.0
@Field
val originY: Double = 0.0
@Field
val width: Double = 0.0
@Field
val height: Double = 0.0
}
/**
* Options to use when saving the resulted image.
*/
class ManipulateOptions : Record {
@Field
val base64: Boolean = false
@Field
val compress: Double = 1.0
@Field
val format: ImageFormat = ImageFormat.JPEG
}
/**
* Possible options for flip action.
*/
enum class FlipType(val value: String) : Enumerable {
VERTICAL("vertical"),
HORIZONTAL("horizontal")
}
/**
* Enum with supported image formats.
*/
enum class ImageFormat(val value: String) : Enumerable {
JPEG("jpeg"),
JPG("jpg"),
PNG("png"),
WEBP("webp");
val fileExtension: String
get() = when (this) {
JPEG, JPG -> ".jpg"
PNG -> ".png"
WEBP -> ".webp"
}
val compressFormat: Bitmap.CompressFormat
get() = when (this) {
JPEG, JPG -> Bitmap.CompressFormat.JPEG
PNG -> Bitmap.CompressFormat.PNG
WEBP -> Bitmap.CompressFormat.WEBP
}
}
@@ -0,0 +1,91 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.imagemanipulator.transformers.ImageTransformer
import expo.modules.kotlin.RuntimeContext
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.toCodedException
import expo.modules.kotlin.sharedobjects.SharedObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
data class ManipulatorResult(
private val value: Bitmap?,
private val error: CodedException?
) {
fun map(transformer: ImageTransformer): ManipulatorResult {
if (error != null) {
return ManipulatorResult(null, error)
}
return try {
ManipulatorResult(
transformer.transform(
requireNotNull(value) { "The result doesn't have a value or error" }
),
null
)
} catch (e: Throwable) {
ManipulatorResult(null, e.toCodedException())
}
}
fun get(): Bitmap {
if (error != null) {
throw error
}
return requireNotNull(value) { "The result doesn't have a value or error" }
}
}
class ManipulatorTask(
private val coroutineScope: CoroutineScope,
private val loader: suspend () -> Bitmap
) {
private var task: Deferred<ManipulatorResult> = launchLoader()
private fun launchLoader(): Deferred<ManipulatorResult> = coroutineScope.async {
try {
ManipulatorResult(loader(), null)
} catch (e: Throwable) {
ManipulatorResult(null, e.toCodedException())
}
}
fun addTransformer(transformer: ImageTransformer) {
val oldTask = task
task = coroutineScope.async {
val currentValue = oldTask.await()
return@async currentValue.map(transformer)
}
}
suspend fun render(): Bitmap {
return task.await().get()
}
fun reset() {
task.cancel()
task = launchLoader()
}
fun cancel() {
task.cancel()
}
}
class ImageManipulatorContext(
runtimeContext: RuntimeContext,
private val task: ManipulatorTask
) : SharedObject(runtimeContext) {
fun addTransformer(transformer: ImageTransformer) = apply { task.addTransformer(transformer) }
fun reset() = apply { task.reset() }
suspend fun render() = task.render()
override fun sharedObjectDidRelease() {
task.cancel()
}
}
@@ -0,0 +1,16 @@
package expo.modules.imagemanipulator
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.DecoratedException
internal class ImageInvalidCropException :
CodedException("Invalid crop options has been passed. Please make sure the requested crop rectangle is inside source image")
internal class ImageLoaderNotFoundException :
CodedException(message = "ImageLoader module not found, make sure 'expo-image-loader' is linked correctly")
internal class ImageLoadingFailedException(image: String, cause: CodedException) :
DecoratedException(message = "Could not load the image: $image", cause)
internal class ImageWriteFailedException(file: String) :
CodedException(message = "Writing image data to the file has failed: $file")
@@ -0,0 +1,148 @@
@file:OptIn(EitherType::class)
package expo.modules.imagemanipulator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Base64
import expo.modules.imagemanipulator.transformers.CropTransformer
import expo.modules.imagemanipulator.transformers.FlipTransformer
import expo.modules.imagemanipulator.transformers.ResizeTransformer
import expo.modules.imagemanipulator.transformers.RotateTransformer
import expo.modules.interfaces.imageloader.ImageLoaderInterface.ResultListener
import expo.modules.kotlin.apifeatures.EitherType
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.exception.toCodedException
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.sharedobjects.SharedRef
import expo.modules.kotlin.types.EitherOfThree
import expo.modules.kotlin.types.toKClass
import kotlinx.coroutines.async
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class ImageManipulatorModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private fun createManipulatorContext(url: Uri): ImageManipulatorContext {
val loader = suspend {
val imageLoader = appContext.imageLoader
?: throw ImageLoaderNotFoundException()
suspendCancellableCoroutine { continuation ->
imageLoader.loadImageForManipulationFromURL(
url.toString(),
object : ResultListener {
override fun onSuccess(bitmap: Bitmap) {
continuation.resume(bitmap)
}
override fun onFailure(cause: Throwable?) {
continuation.resumeWithException(ImageLoadingFailedException(url.toString(), cause.toCodedException()))
}
}
)
}
}
val task = ManipulatorTask(appContext.backgroundCoroutineScope, loader)
return ImageManipulatorContext(runtimeContext, task)
}
private fun createManipulatorContext(bitmap: Bitmap): ImageManipulatorContext {
val task = ManipulatorTask(appContext.backgroundCoroutineScope) { bitmap }
return ImageManipulatorContext(runtimeContext, task)
}
override fun definition() = ModuleDefinition {
Name("ExpoImageManipulator")
Function("manipulate") { url: EitherOfThree<Uri, SharedRef<Bitmap>, SharedRef<Drawable>> ->
return@Function if (url.`is`(Uri::class)) {
createManipulatorContext(url.get(Uri::class))
} else if (url.`is`(toKClass<SharedRef<Bitmap>>())) {
val bitmap = url.get(toKClass<SharedRef<Bitmap>>()).ref
createManipulatorContext(bitmap)
} else {
val drawable = url.get(toKClass<SharedRef<Drawable>>()).ref
val bitmap = (drawable as? BitmapDrawable)?.bitmap
?: throw Exceptions.IllegalArgument("The drawable cannot be converted to a bitmap")
createManipulatorContext(bitmap)
}
}
Class<ImageManipulatorContext>("Context") {
Constructor { url: Uri ->
createManipulatorContext(url)
}
Function("resize") { context: ImageManipulatorContext, options: ResizeOptions ->
context.addTransformer(ResizeTransformer(options))
}
Function("rotate") { context: ImageManipulatorContext, rotation: Float ->
context.addTransformer(RotateTransformer(rotation))
}
Function("flip") { context: ImageManipulatorContext, flipType: FlipType ->
context.addTransformer(FlipTransformer(flipType))
}
Function("crop") { context: ImageManipulatorContext, rect: CropRect ->
context.addTransformer(CropTransformer(rect))
}
Function("reset") { context: ImageManipulatorContext ->
context.reset()
}
AsyncFunction("renderAsync") Coroutine { context: ImageManipulatorContext ->
val image = context.render()
ImageRef(image, runtimeContext)
}
}
Class<ImageRef>("Image") {
Property("width") { image: ImageRef -> image.ref.width }
Property("height") { image: ImageRef -> image.ref.height }
AsyncFunction("saveAsync") Coroutine { image: ImageRef, options: ManipulateOptions? ->
val options = options ?: ManipulateOptions()
val path = FileUtils.generateRandomOutputPath(context, options.format)
val compression = (options.compress * 100).toInt()
val resultBitmap = image.ref
var base64String: String? = null
appContext.backgroundCoroutineScope.async {
FileOutputStream(path).use { fileOut ->
val compressFormat = options.format.compressFormat
resultBitmap.compress(compressFormat, compression, fileOut)
if (options.base64) {
ByteArrayOutputStream().use { byteOut ->
resultBitmap.compress(compressFormat, compression, byteOut)
base64String = Base64.encodeToString(byteOut.toByteArray(), Base64.NO_WRAP)
}
}
}
}.await()
mapOf(
"uri" to Uri.fromFile(File(path)).toString(),
"width" to resultBitmap.width,
"height" to resultBitmap.height,
"base64" to base64String
)
}
}
}
}
@@ -0,0 +1,13 @@
package expo.modules.imagemanipulator
import android.graphics.Bitmap
import expo.modules.kotlin.RuntimeContext
import expo.modules.kotlin.sharedobjects.SharedRef
class ImageRef(bitmap: Bitmap, runtimeContext: RuntimeContext) : SharedRef<Bitmap>(bitmap, runtimeContext) {
override val nativeRefType: String = "image"
override fun getAdditionalMemoryPressure(): Int {
return ref.allocationByteCount
}
}
@@ -0,0 +1,21 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import expo.modules.imagemanipulator.CropRect
import expo.modules.imagemanipulator.ImageInvalidCropException
class CropTransformer(
private val rect: CropRect
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
val isInBounds = rect.originX <= bitmap.width &&
rect.originY <= bitmap.height &&
rect.width <= bitmap.width &&
rect.height <= bitmap.height
if (!isInBounds) {
throw ImageInvalidCropException()
}
return Bitmap.createBitmap(bitmap, rect.originX.toInt(), rect.originY.toInt(), rect.width.toInt(), rect.height.toInt())
}
}
@@ -0,0 +1,21 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import android.graphics.Matrix
import expo.modules.imagemanipulator.FlipType
class FlipTransformer(
private val flipType: FlipType
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, rotationMatrix, true)
}
private val rotationMatrix: Matrix
get() = Matrix().apply {
when (flipType) {
FlipType.VERTICAL -> postScale(1f, -1f)
FlipType.HORIZONTAL -> postScale(-1f, 1f)
}
}
}
@@ -0,0 +1,8 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
@FunctionalInterface
interface ImageTransformer {
fun transform(bitmap: Bitmap): Bitmap
}
@@ -0,0 +1,27 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import expo.modules.imagemanipulator.ResizeOptions
class ResizeTransformer(
private val resizeOptions: ResizeOptions
) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
var targetWidth = 0
var targetHeight = 0
val imageRatio = bitmap.width.toDouble() / bitmap.height.toDouble()
if (resizeOptions.width != null) {
targetWidth = resizeOptions.width
targetHeight = (resizeOptions.width / imageRatio).toInt()
}
if (resizeOptions.height != null) {
targetHeight = resizeOptions.height
targetWidth = if (targetWidth == 0) (resizeOptions.height * imageRatio).toInt() else targetWidth
}
return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
}
}
@@ -0,0 +1,13 @@
package expo.modules.imagemanipulator.transformers
import android.graphics.Bitmap
import android.graphics.Matrix
class RotateTransformer(private val rotation: Float) : ImageTransformer {
override fun transform(bitmap: Bitmap): Bitmap {
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, rotationMatrix, true)
}
private val rotationMatrix: Matrix
get() = Matrix().apply { postRotate(rotation) }
}
+15
View File
@@ -0,0 +1,15 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["ImageManipulatorModule"]
},
"android": {
"modules": ["expo.modules.imagemanipulator.ImageManipulatorModule"],
"publication": {
"groupId": "host.exp.exponent",
"artifactId": "expo.modules.imagemanipulator",
"version": "14.0.8",
"repository": "local-maven-repo"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoImageManipulator'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.swift_version = '5.9'
s.source = { git: 'https://github.com/expo/expo.git' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'EXImageLoader'
s.dependency 'SDWebImageWebPCoder'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
if !$ExpoUseSources&.include?(package['name']) && ENV['EXPO_USE_SOURCE'].to_i == 0 && File.exist?("#{s.name}.xcframework") && Gem::Version.new(Pod::VERSION) >= Gem::Version.new('1.10.0')
s.source_files = "**/*.h"
s.vendored_frameworks = "#{s.name}.xcframework"
else
s.source_files = "**/*.{h,m,swift}"
end
end
@@ -0,0 +1,79 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
/**
Options provided for resize action.
*/
internal struct ResizeOptions: Record {
@Field
var width: CGFloat?
@Field
var height: CGFloat?
}
/**
Cropping rect for crop action.
*/
internal struct CropRect: Record {
@Field
var originX: Double = 0.0
@Field
var originY: Double = 0.0
@Field
var width: Double = 0.0
@Field
var height: Double = 0.0
func toRect() -> CGRect {
return CGRect(x: originX, y: originY, width: width, height: height)
}
}
/**
Options to use when saving the resulted image.
*/
internal struct ManipulateOptions: Record {
@Field
var base64: Bool = false
@Field
var compress: Double = 1.0
@Field
var format: ImageFormat = .jpeg
}
/**
Possible options for flip action.
*/
internal enum FlipType: String, Enumerable {
case vertical
case horizontal
}
/**
Enum with supported image formats.
*/
internal enum ImageFormat: String, Enumerable {
case jpeg
case jpg
case png
case webp
var fileExtension: String {
switch self {
case .jpeg, .jpg:
return ".jpg"
case .png:
return ".png"
case .webp:
return ".webp"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
/**
A context of the image manipulation.
*/
public final class ImageManipulatorContext: SharedObject {
internal typealias Loader = () async throws -> UIImage
/**
The last task added to the rendering pipeline.
*/
private var currentTask: Task<UIImage, Error>
/**
A function that was used to load the original image.
Can be used to reset context's state to the original image.
*/
private let loader: Loader
/**
Initializes a manipulation context with the given loader that returns the original image.
*/
init(loader: @escaping Loader) {
self.loader = loader
currentTask = Task(priority: .background) {
return try await loader()
}
super.init()
}
/**
Adds an image transformer to run on the rendering context in the background.
*/
@discardableResult
internal func addTransformer(_ transformer: ImageTransformer) -> Self {
currentTask = Task(priority: .background) { [currentTask] in
// The task can be canceled in the meantime (e.g. by resetting to the original image).
// In this case there is no reason to transform the image any further.
try Task.checkCancellation()
let image = try await currentTask.value
return try await transformer.transform(image: image)
}
return self
}
/**
Awaits for the last processing task to finish and returns its result.
*/
internal func render() async throws -> UIImage {
return try await currentTask.value
}
/**
Resets the manipulator context to the originally loaded image.
*/
internal func reset() {
// Firstly cancel currently running manipulations.
currentTask.cancel()
currentTask = Task(priority: .background) {
return try await loader()
}
}
}
@@ -0,0 +1,82 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
internal final class ImageContextLostException: Exception {
override var reason: String {
"Image context has been lost"
}
}
internal final class ImageDrawingFailedException: Exception {
override var reason: String {
"Drawing the new image failed"
}
}
internal final class ImageNotFoundException: Exception {
override var reason: String {
"Image cannot be found"
}
}
internal final class ImageColorSpaceNotFoundException: Exception {
override var reason: String {
"The image does not specify any color space"
}
}
internal final class ImageInvalidCropException: Exception {
override var reason: String {
"Invalid crop options has been passed. Please make sure the requested crop rectangle is inside source image"
}
}
internal final class ImageCropFailedException: GenericException<CGRect> {
override var reason: String {
"Cropping the image to rectangle (x: \(param.origin.x), y: \(param.origin.y), width: \(param.width), height: \(param.height)) has failed"
}
}
internal final class NoImageInContextException: Exception {
override var reason: String {
"Could not read the image from the drawing context"
}
}
internal final class ImageLoaderNotFoundException: Exception {
override var reason: String {
"ImageLoader module not found, make sure 'expo-image-loader' is linked correctly"
}
}
internal final class FileSystemNotFoundException: Exception {
override var reason: String {
"FileSystem module not found, make sure 'expo-file-system' is linked correctly"
}
}
internal final class FileSystemReadPermissionException: GenericException<String> {
override var reason: String {
"File '\(param)' is not readable"
}
}
internal final class ImageLoadingFailedException: GenericException<String> {
override var reason: String {
"Could not load the image: \(param)"
}
}
internal final class CorruptedImageDataException: Exception {
override var reason: String {
"Cannot create image data for given image format"
}
}
internal final class ImageWriteFailedException: GenericException<String> {
override var reason: String {
"Writing image data to the file has failed: \(param)"
}
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2021-present 650 Industries. All rights reserved.
import CoreGraphics
import Photos
import UIKit
import ExpoModulesCore
import SDWebImageWebPCoder
public class ImageManipulatorModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoImageManipulator")
Function("manipulate") { (source: Either<URL, SharedRef<UIImage>>) -> ImageManipulatorContext in
let context = ImageManipulatorContext { [weak appContext] in
guard let appContext else {
throw Exceptions.AppContextLost()
}
if let url: URL = source.get() {
return try await loadImage(atUrl: url, appContext: appContext)
}
if let image: SharedRef<UIImage> = source.get() {
return image.ref
}
throw Exceptions.RuntimeLost()
}
// Immediately try to fix the orientation once the image is loaded
context.addTransformer(ImageFixOrientationTransformer())
return context
}
Class("Context", ImageManipulatorContext.self) {
Function("resize") { (context: ImageManipulatorContext, options: ResizeOptions) -> ImageManipulatorContext in
return context.addTransformer(ImageResizeTransformer(options: options))
}
Function("rotate") { (context: ImageManipulatorContext, rotate: Double) -> ImageManipulatorContext in
return context.addTransformer(ImageRotateTransformer(rotate: rotate))
}
Function("flip") { (context: ImageManipulatorContext, flipType: FlipType) -> ImageManipulatorContext in
return context.addTransformer(ImageFlipTransformer(flip: flipType))
}
Function("crop") { (context: ImageManipulatorContext, rect: CropRect) -> ImageManipulatorContext in
return context.addTransformer(ImageCropTransformer(options: rect))
}
Function("reset") { (context: ImageManipulatorContext) -> ImageManipulatorContext in
context.reset()
return context
}
AsyncFunction("renderAsync") { (context: ImageManipulatorContext) -> ImageRef in
let image = try await context.render()
return ImageRef(image)
}
}
Class("Image", ImageRef.self) {
Property("width") { (image: ImageRef) -> Int in
return image.ref.cgImage?.width ?? 0
}
Property("height") { (image: ImageRef) -> Int in
return image.ref.cgImage?.height ?? 0
}
AsyncFunction("saveAsync") { (image: ImageRef, options: ManipulateOptions?) -> [String: Any?] in
guard let appContext else {
throw Exceptions.AppContextLost()
}
let options = options ?? ManipulateOptions()
let result = try saveImage(image.ref, options: options, appContext: appContext)
// We're returning a dict instead of a path directly because in the future we'll replace it
// with a shared ref to the file once this feature gets implemented in expo-file-system.
// This should be fully backwards-compatible switch.
return [
"uri": result.url.absoluteString,
"width": image.ref.cgImage?.width ?? 0,
"height": image.ref.cgImage?.height ?? 0,
"base64": options.base64 ? result.data.base64EncodedString() : nil
]
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import SDWebImageWebPCoder
import Photos
import ExpoModulesCore
internal typealias SaveImageResult = (url: URL, data: Data)
/**
Loads the image from given URL.
*/
internal func loadImage(atUrl url: URL, appContext: AppContext) async throws -> UIImage {
if url.scheme == "data" {
guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else {
throw CorruptedImageDataException()
}
return image
}
if url.scheme == "ph" || url.scheme == "assets-library" {
return try await loadImageFromPhotoLibrary(url: url)
}
guard let imageLoader = appContext.imageLoader else {
throw ImageLoaderNotFoundException()
}
guard FileSystemUtilities.permissions(appContext, for: url).contains(.read) && FileManager.default.isReadableFile(atPath: url.path) else {
throw FileSystemReadPermissionException(url.absoluteString)
}
do {
if let result = try await imageLoader.loadImage(for: url) {
return result
}
} catch {
throw ImageLoadingFailedException((error as NSError).debugDescription)
}
// TODO: throw something better
throw ImageLoadingFailedException("")
}
/**
Loads the image from user's photo library.
*/
internal func loadImageFromPhotoLibrary(url: URL) async throws -> UIImage {
guard let asset = retrieveAsset(from: url) else {
throw ImageNotFoundException()
}
let size = CGSize(width: asset.pixelWidth, height: asset.pixelHeight)
let options = PHImageRequestOptions()
options.resizeMode = .exact
options.isNetworkAccessAllowed = true
options.isSynchronous = true
options.deliveryMode = .highQualityFormat
return try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFit, options: options) { image, _ in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(throwing: ImageNotFoundException())
}
}
}
}
/**
Returns pixel data representation of the image.
*/
func imageData(from image: UIImage, format: ImageFormat, compression: Double) -> Data? {
switch format {
case .jpeg, .jpg:
return image.jpegData(compressionQuality: compression)
case .png:
return image.pngData()
case .webp:
return SDImageWebPCoder.shared.encodedData(with: image, format: .webP, options: [.encodeCompressionQuality: compression])
}
}
/**
Checks if we are dealing with a ph asset URL and uses the correct method to fetch it.
*/
func retrieveAsset(from url: URL) -> PHAsset? {
if url.scheme == "ph" {
let identifier = String(url.absoluteString.dropFirst(5)) // removes ph://
return PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject
}
return PHAsset.fetchAssets(withALAssetURLs: [url], options: nil).firstObject
}
/**
Helper function for drawing the image in graphics context.
Throws appropriate exceptions when the context is missing or the image couldn't be rendered.
*/
internal func drawInNewContext(size: CGSize, drawing: (UIGraphicsImageRendererContext) -> Void) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { context in
drawing(context)
}
}
/**
Saves the image as a file.
*/
internal func saveImage(_ image: UIImage, options: ManipulateOptions, appContext: AppContext) throws -> SaveImageResult {
guard let cachesDirectory = appContext.config.cacheDirectory else {
throw FileSystemNotFoundException()
}
let directory = URL(fileURLWithPath: cachesDirectory.path).appendingPathComponent("ImageManipulator")
let filename = UUID().uuidString.appending(options.format.fileExtension)
let fileUrl = directory.appendingPathComponent(filename)
FileSystemUtilities.ensureDirExists(at: directory)
guard let data = imageData(from: image, format: options.format, compression: options.compress) else {
throw CorruptedImageDataException()
}
do {
try data.write(to: fileUrl, options: .atomic)
} catch let error {
throw ImageWriteFailedException(error.localizedDescription)
}
return (url: fileUrl, data: data)
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2024-present 650 Industries. All rights reserved.
import ExpoModulesCore
/**
Represents a shared reference to the `UIImage` instance.
*/
internal final class ImageRef: SharedRef<UIImage> {
override var nativeRefType: String {
"image"
}
override func getAdditionalMemoryPressure() -> Int {
guard let cgImage = ref.cgImage else {
return 0
}
return cgImage.bytesPerRow * cgImage.height
}
}
+5
View File
@@ -0,0 +1,5 @@
// Copyright 2024-present 650 Industries. All rights reserved.
internal protocol ImageTransformer {
func transform(image: UIImage) async throws -> UIImage
}
@@ -0,0 +1,24 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by cropping given image to specified width and height.
*/
internal struct ImageCropTransformer: ImageTransformer {
let options: CropRect
func transform(image: UIImage) async throws -> UIImage {
let rect = options.toRect()
let isOutOfBounds = rect.origin.x > image.size.width
|| rect.origin.y > image.size.height
|| rect.width > image.size.width
|| rect.height > image.size.height
guard !isOutOfBounds else {
throw ImageInvalidCropException()
}
guard let cgImage = image.cgImage?.cropping(to: rect) else {
throw ImageCropFailedException(rect)
}
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
}
@@ -0,0 +1,75 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that makes sure the image is oriented up and not mirrored.
Guarantees that the original pixel data matches the displayed orientation.
*/
internal struct ImageFixOrientationTransformer: ImageTransformer {
func transform(image: UIImage) async throws -> UIImage {
guard let cgImage = image.cgImage else {
throw ImageNotFoundException()
}
guard var colorSpace = cgImage.colorSpace else {
// That should never happen as `colorSpace` is empty only when the image is a mask.
throw ImageColorSpaceNotFoundException()
}
if !colorSpace.supportsOutput {
colorSpace = CGColorSpaceCreateDeviceRGB()
}
var transform = CGAffineTransform.identity
switch image.imageOrientation {
case .down, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: image.size.height)
transform = transform.rotated(by: Double.pi)
case .left, .leftMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.rotated(by: Double.pi / 2)
case .right, .rightMirrored:
transform = transform.translatedBy(x: 0, y: image.size.height)
transform = transform.rotated(by: -Double.pi / 2)
default:
break
}
switch image.imageOrientation {
case .upMirrored, .downMirrored:
transform = transform.translatedBy(x: image.size.width, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
case .leftMirrored, .rightMirrored:
transform = transform.translatedBy(x: image.size.height, y: 0)
transform = transform.scaledBy(x: -1, y: 1)
default:
break
}
let context = CGContext(
data: nil,
width: Int(image.size.width),
height: Int(image.size.height),
bitsPerComponent: cgImage.bitsPerComponent,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
guard let context = context else {
throw ImageContextLostException()
}
context.concatenate(transform)
switch image.imageOrientation {
case .left, .leftMirrored, .right, .rightMirrored:
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.height, height: image.size.width))
default:
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
guard let newCGImage = context.makeImage() else {
throw ImageDrawingFailedException()
}
return UIImage(cgImage: newCGImage)
}
}
@@ -0,0 +1,25 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by flipping given image vertically or horizontally.
*/
internal struct ImageFlipTransformer: ImageTransformer {
let flip: FlipType
@MainActor
func transform(image: UIImage) async -> UIImage {
let imageView = UIImageView(image: image)
return drawInNewContext(size: imageView.frame.size) { context in
switch flip {
case .vertical:
let transform = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: imageView.frame.size.height)
context.cgContext.concatenate(transform)
case .horizontal:
let transform = CGAffineTransform(a: -1, b: 0, c: 0, d: 1, tx: imageView.frame.size.width, ty: 0)
context.cgContext.concatenate(transform)
}
imageView.layer.render(in: context.cgContext)
}
}
}
@@ -0,0 +1,34 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that draws a new image by resizing given image to specified size.
*/
internal struct ImageResizeTransformer: ImageTransformer {
let options: ResizeOptions
func transform(image: UIImage) async -> UIImage {
let imageWidth = image.size.width
let imageHeight = image.size.height
let imageRatio = imageWidth / imageHeight
var targetSize = CGSize.zero
if let width = options.width {
targetSize.width = width
targetSize.height = width / imageRatio
}
if let height = options.height {
targetSize.height = height
targetSize.width = targetSize.width == 0 ? imageRatio * targetSize.height : targetSize.width
}
let format = UIGraphicsImageRendererFormat()
format.opaque = false
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}
@@ -0,0 +1,30 @@
// Copyright 2024-present 650 Industries. All rights reserved.
/**
Transformer that creates a new image by rotating given image by the rotate angle.
*/
internal struct ImageRotateTransformer: ImageTransformer {
let rotate: Double
@MainActor
func transform(image: UIImage) async throws -> UIImage {
guard let cgImage = image.cgImage else {
throw ImageNotFoundException()
}
let rads = rotate * .pi / 180
let rotatedView = UIView(frame: CGRect(origin: .zero, size: image.size))
rotatedView.transform = CGAffineTransform(rotationAngle: rads)
let rotatedSize = CGSize(width: rotatedView.frame.size.width.rounded(.down), height: rotatedView.frame.size.height.rounded(.down))
let origin = CGPoint(x: -image.size.width / 2, y: -image.size.height / 2)
return drawInNewContext(size: rotatedSize) { context in
let cgContext = context.cgContext
cgContext.translateBy(x: rotatedSize.width / 2, y: rotatedSize.height / 2)
cgContext.rotate(by: rads)
cgContext.scaleBy(x: 1.0, y: -1.0)
cgContext.draw(cgImage, in: CGRect(origin: origin, size: image.size))
}
}
}
@@ -0,0 +1 @@
83452b235a0ea4b9ed9dc693de4fdda6a7940c15c27b9033fb0e398e00b56a7b
@@ -0,0 +1 @@
08af6c95ebf4df88f09274f2dc172df230d57b8f3ca2d1774d44584282b25d9d93ba57a78a2cdfef9232145c67aa8765b8df86f6c79852ba8c1b6b3eb3c21350
@@ -0,0 +1 @@
d17bd551626089157b2c6c4bf741ea463e2b1e592804f26495e774fd90a16b33
@@ -0,0 +1 @@
d8d435b830a1dbb2d6230a113b4605de27bd1fff1d6fbf37ebd770632f61656bf8f7b0d4f408713df1be79ffe44d183abda936ad8a5cf7580cc16078c04276c8
@@ -0,0 +1,103 @@
{
"formatVersion": "1.1",
"component": {
"group": "host.exp.exponent",
"module": "expo.modules.imagemanipulator",
"version": "14.0.8",
"attributes": {
"org.gradle.status": "release"
}
},
"createdBy": {
"gradle": {
"version": "8.14.3"
}
},
"variants": [
{
"name": "releaseVariantReleaseApiPublication",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.libraryelements": "aar",
"org.gradle.usage": "java-api"
},
"dependencies": [
{
"group": "androidx.annotation",
"module": "annotation",
"version": {
"requires": "1.0.0"
}
}
],
"files": [
{
"name": "expo.modules.imagemanipulator-14.0.8.aar",
"url": "expo.modules.imagemanipulator-14.0.8.aar",
"size": 74861,
"sha512": "d8d435b830a1dbb2d6230a113b4605de27bd1fff1d6fbf37ebd770632f61656bf8f7b0d4f408713df1be79ffe44d183abda936ad8a5cf7580cc16078c04276c8",
"sha256": "d17bd551626089157b2c6c4bf741ea463e2b1e592804f26495e774fd90a16b33",
"sha1": "12f15ef6d33811a5cfe744a41afaa9b07fb90707",
"md5": "3b556fa1d971cb956ac33c76aa2dd3f6"
}
]
},
{
"name": "releaseVariantReleaseRuntimePublication",
"attributes": {
"org.gradle.category": "library",
"org.gradle.dependency.bundling": "external",
"org.gradle.libraryelements": "aar",
"org.gradle.usage": "java-runtime"
},
"dependencies": [
{
"group": "org.jetbrains.kotlin",
"module": "kotlin-stdlib-jdk7",
"version": {
"requires": "2.1.20"
}
},
{
"group": "androidx.annotation",
"module": "annotation",
"version": {
"requires": "1.0.0"
}
}
],
"files": [
{
"name": "expo.modules.imagemanipulator-14.0.8.aar",
"url": "expo.modules.imagemanipulator-14.0.8.aar",
"size": 74861,
"sha512": "d8d435b830a1dbb2d6230a113b4605de27bd1fff1d6fbf37ebd770632f61656bf8f7b0d4f408713df1be79ffe44d183abda936ad8a5cf7580cc16078c04276c8",
"sha256": "d17bd551626089157b2c6c4bf741ea463e2b1e592804f26495e774fd90a16b33",
"sha1": "12f15ef6d33811a5cfe744a41afaa9b07fb90707",
"md5": "3b556fa1d971cb956ac33c76aa2dd3f6"
}
]
},
{
"name": "releaseVariantReleaseSourcePublication",
"attributes": {
"org.gradle.category": "documentation",
"org.gradle.dependency.bundling": "external",
"org.gradle.docstype": "sources",
"org.gradle.usage": "java-runtime"
},
"files": [
{
"name": "expo.modules.imagemanipulator-14.0.8-sources.jar",
"url": "expo.modules.imagemanipulator-14.0.8-sources.jar",
"size": 8159,
"sha512": "08af6c95ebf4df88f09274f2dc172df230d57b8f3ca2d1774d44584282b25d9d93ba57a78a2cdfef9232145c67aa8765b8df86f6c79852ba8c1b6b3eb3c21350",
"sha256": "83452b235a0ea4b9ed9dc693de4fdda6a7940c15c27b9033fb0e398e00b56a7b",
"sha1": "b9b08b0aca5c25507ec419236d8895a87df02585",
"md5": "71622281255feb4dcaf797a74964698b"
}
]
}
]
}
@@ -0,0 +1 @@
13191f571bb064f7784e51d57461164c5139cb1dc9a104050d7357faff5ac52f
@@ -0,0 +1 @@
f80e71e63352cb0e927f01de38275cc7f2deae494fe20001cde2c9280366381d84fd2fbb25a268afbb1d0a49ef904ce71f85ac74f3a7ff791580f6524d63e097
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- This module was also published with a richer model, Gradle metadata, -->
<!-- which should be used instead. Do not delete the following line which -->
<!-- is to indicate to Gradle or any Gradle module metadata file consumer -->
<!-- that they should prefer consuming it instead. -->
<!-- do_not_remove: published-with-gradle-metadata -->
<modelVersion>4.0.0</modelVersion>
<groupId>host.exp.exponent</groupId>
<artifactId>expo.modules.imagemanipulator</artifactId>
<version>14.0.8</version>
<packaging>aar</packaging>
<name>expo.modules.imagemanipulator</name>
<url>https://github.com/expo/expo</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://github.com/expo/expo/blob/main/LICENSE</url>
</license>
</licenses>
<scm>
<connection>https://github.com/expo/expo.git</connection>
<developerConnection>https://github.com/expo/expo.git</developerConnection>
<url>https://github.com/expo/expo</url>
</scm>
<dependencies>
<dependency>
<groupId>androidx.annotation</groupId>
<artifactId>annotation</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk7</artifactId>
<version>2.1.20</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1 @@
5e9b402133fe28bd9c9825a027628b163fea1dbcf86024e383177ac8b5698a09
@@ -0,0 +1 @@
85c72a628753a6b7e101ba9cabcc576579b44cb2ffa48dca4315b45c2c62c808d4ebdc6bff7aaecb0585ed7790b108ea2868e9deb22bcc31bbbde298062c09a3
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>host.exp.exponent</groupId>
<artifactId>expo.modules.imagemanipulator</artifactId>
<versioning>
<latest>14.0.8</latest>
<release>14.0.8</release>
<versions>
<version>14.0.8</version>
</versions>
<lastUpdated>20251205064553</lastUpdated>
</versioning>
</metadata>
@@ -0,0 +1 @@
92a5bfa319c4cadf0d23323dae58b89b
@@ -0,0 +1 @@
135c796c9b4ce76499159d56653cae1e0b534caa
@@ -0,0 +1 @@
c2d51d0f4dea25c64d26a365243a762deeeed576b0192ce03e173ed9f224dd63
@@ -0,0 +1 @@
b35852c351368ba1e90c3ace2a9a6e2d5453b56cdaad23406d75b63639c120e0d07a7852ede983a147401f900df8da0efffe80caa868c1221acd3a21c9673d2f
+43
View File
@@ -0,0 +1,43 @@
{
"name": "expo-image-manipulator",
"version": "14.0.8",
"description": "Provides functions that let you manipulation images on the local file system, eg: resize, crop.",
"main": "src/index.ts",
"types": "build/index.d.ts",
"sideEffects": false,
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": [
"react-native",
"expo",
"expo-image-manipulator"
],
"repository": {
"type": "git",
"url": "https://github.com/expo/expo.git",
"directory": "packages/expo-image-manipulator"
},
"bugs": {
"url": "https://github.com/expo/expo/issues"
},
"author": "650 Industries, Inc.",
"license": "MIT",
"homepage": "https://docs.expo.dev/versions/latest/sdk/imagemanipulator/",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"devDependencies": {
"expo-module-scripts": "^5.0.8"
},
"peerDependencies": {
"expo": "*"
},
"gitHead": "172a69f5f70c1d0e043e1532f924de97210cabc3"
}
+60
View File
@@ -0,0 +1,60 @@
import { useReleasingSharedObject } from 'expo-modules-core';
import { SharedRef } from 'expo-modules-core/types';
import { Action, ImageResult, SaveFormat, SaveOptions } from './ImageManipulator.types';
import { ImageManipulatorContext } from './ImageManipulatorContext';
import ExpoImageManipulator from './NativeImageManipulatorModule';
import { validateArguments } from './validators';
// @needsAudit
/**
* Manipulate the image provided via `uri`. Available modifications are rotating, flipping (mirroring),
* resizing and cropping. Each invocation results in a new file. With one invocation you can provide
* a set of actions to perform over the image. Overwriting the source file would not have an effect
* in displaying the result as images are cached.
* @param uri URI of the file to manipulate. Should be on the local file system or a base64 data URI.
* @param actions An array of objects representing manipulation options. Each object should have
* __only one__ of the keys that corresponds to specific transformation.
* @param saveOptions A map defining how modified image should be saved.
* @return Promise which fulfils with [`ImageResult`](#imageresult) object.
* @deprecated It has been replaced by the new, contextual and object-oriented API.
* Use [`ImageManipulator.manipulate`](#manipulatesource) or [`useImageManipulator`](#useimagemanipulatorsource) instead.
*/
export async function manipulateAsync(
uri: string,
actions: Action[] = [],
saveOptions: SaveOptions = {}
): Promise<ImageResult> {
validateArguments(uri, actions, saveOptions);
const { format = SaveFormat.JPEG, ...rest } = saveOptions;
const context = ExpoImageManipulator.manipulate(uri);
for (const action of actions) {
if ('resize' in action) {
context.resize(action.resize);
} else if ('rotate' in action) {
context.rotate(action.rotate);
} else if ('flip' in action) {
context.flip(action.flip);
} else if ('crop' in action) {
context.crop(action.crop);
} else if ('extent' in action && context.extent) {
context.extent(action.extent);
}
}
const image = await context.renderAsync();
const result = await image.saveAsync({ format, ...rest });
// These shared objects will not be used anymore, so free up some memory.
context.release();
image.release();
return result;
}
export function useImageManipulator(source: string | SharedRef<'image'>): ImageManipulatorContext {
return useReleasingSharedObject(() => ExpoImageManipulator.manipulate(source), [source]);
}
export { ExpoImageManipulator as ImageManipulator };
+143
View File
@@ -0,0 +1,143 @@
import type { NativeModule } from 'expo';
import { SharedRef } from 'expo-modules-core/types';
import type { ImageManipulatorContext } from './ImageManipulatorContext';
import ImageRef from './ImageRef';
// @needsAudit
export type ImageResult = {
/**
* An URI to the modified image (usable as the source for an `Image` or `Video` element).
*/
uri: string;
/**
* Width of the image or video.
*/
width: number;
/**
* Height of the image or video.
*/
height: number;
/**
* It is included if the `base64` save option was truthy, and is a string containing the
* JPEG/PNG (depending on `format`) data of the image in Base64. Prepend that with `'data:image/xxx;base64,'`
* to get a data URI, which you can use as the source for an `Image` element for example
* (where `xxx` is `jpeg` or `png`).
*/
base64?: string;
};
// @needsAudit
export type ActionResize = {
/**
* Values correspond to the result image dimensions. If you specify only one value, the other will
* be calculated automatically to preserve image ratio.
*/
resize: {
width?: number;
height?: number;
};
};
// @needsAudit
export type ActionRotate = {
/**
* Degrees to rotate the image. Rotation is clockwise when the value is positive and
* counter-clockwise when negative.
*/
rotate: number;
};
// @docsMissing
export enum FlipType {
Vertical = 'vertical',
Horizontal = 'horizontal',
}
// @needsAudit
export type ActionFlip = {
/**
* An axis on which image will be flipped. Only one flip per transformation is available. If you
* want to flip according to both axes then provide two separate transformations.
*/
flip: FlipType;
};
// @needsAudit
export type ActionCrop = {
/**
* Fields specify top-left corner and dimensions of a crop rectangle.
*/
crop: {
originX: number;
originY: number;
width: number;
height: number;
};
};
// @needsAudit
export type ActionExtent = {
/**
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
* To position the image, use `originX` and `originY`.
*
* @platform web
*/
extent: {
backgroundColor?: string | null;
originX?: number;
originY?: number;
width: number;
height: number;
};
};
// @docsMissing
export type Action = ActionResize | ActionRotate | ActionFlip | ActionCrop | ActionExtent;
// @docsMissing
export enum SaveFormat {
JPEG = 'jpeg',
PNG = 'png',
WEBP = 'webp',
}
// @needsAudit
/**
* A map defining how modified image should be saved.
*/
export type SaveOptions = {
/**
* Whether to also include the image data in Base64 format.
*/
base64?: boolean;
/**
* 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).
*/
compress?: number;
/**
* Specifies what type of compression should be used and what is the result file extension.
* `SaveFormat.PNG` compression is lossless but slower, `SaveFormat.JPEG` is faster but the image
* has visible artifacts. Defaults to `SaveFormat.JPEG`
*/
format?: SaveFormat;
};
export declare class ImageManipulator extends NativeModule {
/**
* @hidden
*/
Context: typeof ImageManipulatorContext;
/**
* @hidden
*/
Image: typeof ImageRef;
/**
* Loads an image from the given URI and creates a new image manipulation context.
*/
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext;
}
+58
View File
@@ -0,0 +1,58 @@
import { SharedObject } from 'expo';
import type { ActionCrop, ActionExtent } from './ImageManipulator.types';
import type { ImageRef } from './ImageRef';
import ExpoImageManipulator from './NativeImageManipulatorModule';
/**
* A context for an image manipulation. It provides synchronous, chainable functions that schedule transformations on the original image to the background thread.
* Use an asynchronous [`renderAsync`](#renderasync) to await for all transformations to finish and access the final image.
*/
export declare class ImageManipulatorContext extends SharedObject {
/**
* Resizes the image to the given size.
* @param size Values correspond to the result image dimensions. If you specify only one value, the other will
* be calculated automatically to preserve image ratio.
*/
resize(size: { width?: number | null; height?: number | null }): ImageManipulatorContext;
/**
* Rotates the image by the given number of degrees.
* @param degrees Degrees to rotate the image. Rotation is clockwise when the value is positive and
* counter-clockwise when negative.
*/
rotate(degrees: number): ImageManipulatorContext;
/**
* Flips the image vertically or horizontally.
* @param flipType An axis on which image will be flipped. Only one flip per transformation is available. If you
* want to flip according to both axes then provide two separate transformations.
*/
flip(flipType: 'vertical' | 'horizontal'): ImageManipulatorContext;
/**
* Crops the image to the given rectangle's origin and size.
* @param rect Fields specify top-left corner and dimensions of a crop rectangle.
*/
crop(rect: ActionCrop['crop']): ImageManipulatorContext;
/**
* Set the image size and offset. If the image is enlarged, unfilled areas are set to the `backgroundColor`.
* To position the image, use `originX` and `originY`.
*
* @platform web
*/
extent(options: ActionExtent['extent']): ImageManipulatorContext;
/**
* Resets the manipulator context to the originally loaded image.
*/
reset(): ImageManipulatorContext;
/**
* Awaits for all manipulation tasks to finish and resolves with a reference to the resulted native image.
*/
renderAsync(): Promise<ImageRef>;
}
export default ExpoImageManipulator.Context as typeof ImageManipulatorContext;
+27
View File
@@ -0,0 +1,27 @@
import { SharedRef } from 'expo';
import type { ImageResult, SaveOptions } from './ImageManipulator.types';
import ExpoImageManipulator from './NativeImageManipulatorModule';
/**
* A reference to a native instance of the image.
*/
export declare class ImageRef extends SharedRef<'image'> {
/**
* Width of the image.
*/
width: number;
/**
* Height of the image.
*/
height: number;
/**
* Saves the image to the file system in the cache directory.
* @param options A map defining how modified image should be saved.
*/
saveAsync(options?: SaveOptions): Promise<ImageResult>;
}
export default ExpoImageManipulator.ImageRef as typeof ImageRef;
@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo';
import { ImageManipulator } from './ImageManipulator.types';
export default requireNativeModule<ImageManipulator>('ExpoImageManipulator');
@@ -0,0 +1,27 @@
import { NativeModule } from 'expo';
import { registerWebModule } from 'expo-modules-core';
import { SharedRef } from 'expo-modules-core/types';
import ImageManipulatorContext from './web/ImageManipulatorContext.web';
import ImageManipulatorImageRef from './web/ImageManipulatorImageRef.web';
import { loadImageAsync } from './web/utils.web';
class ImageManipulator extends NativeModule {
Context = ImageManipulatorContext;
Image = ImageManipulatorImageRef;
manipulate(source: string | SharedRef<'image'>): ImageManipulatorContext {
return new ImageManipulatorContext(() => {
if (typeof source === 'string') {
return loadImageAsync(source);
}
// Image refs should provide the `uri` property on Web. It could be either remote url, blob or data url.
if (typeof source === 'object' && 'uri' in source && typeof source.uri === 'string') {
return loadImageAsync(source.uri);
}
throw new Error(`Source not supported: ${source}`);
});
}
}
export default registerWebModule(ImageManipulator, 'ImageManipulator');
+19
View File
@@ -0,0 +1,19 @@
export { ImageManipulator, manipulateAsync, useImageManipulator } from './ImageManipulator';
// SaveFormat and FlipType are enums
export { SaveFormat, FlipType } from './ImageManipulator.types';
export type { SaveOptions, ImageResult } from './ImageManipulator.types';
// Export types that are deprecated as of SDK 52
export type {
ActionResize,
ActionRotate,
ActionFlip,
ActionCrop,
ActionExtent,
Action,
} from './ImageManipulator.types';
export type { ImageRef } from './ImageRef';
export type { ImageManipulatorContext } from './ImageManipulatorContext';
+134
View File
@@ -0,0 +1,134 @@
import {
Action,
ActionCrop,
ActionExtent,
ActionFlip,
ActionResize,
ActionRotate,
FlipType,
SaveFormat,
SaveOptions,
} from './ImageManipulator.types';
export function validateArguments(uri: string, actions: Action[], saveOptions: SaveOptions) {
validateUri(uri);
validateActions(actions);
validateSaveOptions(saveOptions);
}
export function validateUri(uri: string): void {
if (!(typeof uri === 'string')) {
throw new TypeError('The "uri" argument must be a string');
}
}
export function validateActions(actions: Action[]): void {
if (!Array.isArray(actions)) {
throw new TypeError('The "actions" argument must be an array');
}
for (const action of actions) {
if (typeof action !== 'object' || action === null) {
throw new TypeError('Action must be an object');
}
const supportedActionTypes = ['crop', 'extent', 'flip', 'rotate', 'resize'];
const actionKeys = Object.keys(action);
if (actionKeys.length !== 1) {
throw new TypeError(
`Single action must contain exactly one transformation: ${supportedActionTypes.join(', ')}`
);
}
const actionType = actionKeys[0];
if (!supportedActionTypes.includes(actionType)) {
throw new TypeError(`Unsupported action type: ${actionType}`);
}
if (actionType === 'crop') {
validateCropAction(action as ActionCrop);
} else if (actionType === 'extent') {
validateExtentAction(action as ActionExtent);
} else if (actionType === 'flip') {
validateFlipAction(action as ActionFlip);
} else if (actionType === 'rotate') {
validateRotateAction(action as ActionRotate);
} else if (actionType === 'resize') {
validateResizeAction(action as ActionResize);
}
}
}
function validateCropAction(action: ActionCrop): void {
const isValid =
typeof action.crop === 'object' &&
action.crop !== null &&
typeof action.crop.originX === 'number' &&
typeof action.crop.originY === 'number' &&
typeof action.crop.width === 'number' &&
typeof action.crop.height === 'number';
if (!isValid) {
throw new TypeError(
'Crop action must be an object of shape { originX: number; originY: number; width: number; height: number }'
);
}
}
function validateExtentAction(action: ActionExtent): void {
const isValid =
typeof action.extent === 'object' &&
action.extent !== null &&
(action.extent.backgroundColor == null || typeof action.extent.backgroundColor === 'string') &&
(action.extent.originX == null || typeof action.extent.originX === 'number') &&
(action.extent.originY == null || typeof action.extent.originY === 'number') &&
typeof action.extent.width === 'number' &&
typeof action.extent.height === 'number';
if (!isValid) {
throw new TypeError(
'Extent action must be an object of shape { backgroundColor?: string; originX?: number; originY?: number; width: number; height: number }'
);
}
}
function validateFlipAction(action: ActionFlip): void {
if (
typeof action.flip !== 'string' ||
![FlipType.Horizontal, FlipType.Vertical].includes(action.flip)
) {
throw new TypeError(`Unsupported flip type: ${action.flip}`);
}
}
function validateRotateAction(action: ActionRotate): void {
if (typeof action.rotate !== 'number') {
throw new TypeError('Rotation must be a number');
}
}
function validateResizeAction(action: ActionResize): void {
const isValid =
typeof action.resize === 'object' &&
action.resize !== null &&
(typeof action.resize.width === 'number' || typeof action.resize.width === 'undefined') &&
(typeof action.resize.height === 'number' || typeof action.resize.height === 'undefined');
if (!isValid) {
throw new TypeError(
'Resize action must be an object of shape { width?: number; height?: number }'
);
}
}
export function validateSaveOptions({ base64, compress, format }: SaveOptions): void {
if (base64 !== undefined && typeof base64 !== 'boolean') {
throw new TypeError('The "base64" argument must be a boolean');
}
if (compress !== undefined) {
if (typeof compress !== 'number') {
throw new TypeError('The "compress" argument must be a number');
}
if (compress < 0 || compress > 1) {
throw new TypeError('The "compress" argument must be a number between 0 and 1');
}
}
const allowedFormats = [SaveFormat.JPEG, SaveFormat.PNG, SaveFormat.WEBP];
if (format !== undefined && !allowedFormats.includes(format)) {
throw new TypeError(`The "format" argument must be one of: ${allowedFormats.join(', ')}`);
}
}
@@ -0,0 +1,90 @@
import { SharedObject } from 'expo';
import { ActionCrop, ActionExtent, FlipType } from '../ImageManipulator.types';
import ImageManipulatorImageRef from './ImageManipulatorImageRef.web';
import { crop, extent, flip, resize, rotate } from './actions/index.web';
type ContextLoader = () => HTMLCanvasElement | Promise<HTMLCanvasElement>;
export default class ImageManipulatorContext extends SharedObject {
private loader: ContextLoader;
private _currentTask: Promise<HTMLCanvasElement> | undefined;
get currentTask() {
if (this._currentTask) {
return this._currentTask;
}
this._currentTask = new Promise((resolve) => resolve(this.loader()));
return this._currentTask;
}
set currentTask(task) {
this._currentTask = task;
}
constructor(loader?: ContextLoader) {
super();
this.loader = loader ?? (() => document.createElement('canvas'));
}
resize(size: { width: number; height: number }): ImageManipulatorContext {
return this.addTask((canvas) => resize(canvas, size));
}
rotate(degrees: number): ImageManipulatorContext {
return this.addTask((canvas) => rotate(canvas, degrees));
}
flip(flipType: FlipType): ImageManipulatorContext {
return this.addTask((canvas) => flip(canvas, flipType));
}
crop(rect: ActionCrop['crop']): ImageManipulatorContext {
return this.addTask((canvas) => crop(canvas, rect));
}
extent(options: ActionExtent['extent']): ImageManipulatorContext {
return this.addTask((canvas) => extent(canvas, options));
}
reset(): ImageManipulatorContext {
this.currentTask = new Promise((resolve) => resolve(this.loader()));
return this;
}
async renderAsync(): Promise<ImageManipulatorImageRef> {
const canvas = await this.currentTask;
// We're copying the canvas so ref's `saveAsync` can safely use `toBlob` again with the desired format and quality.
// The original canvas cannot be reused as the manipulator context may still draw on it.
const clonedCanvas = document.createElement('canvas');
const clonedCanvasCtx = clonedCanvas.getContext('2d');
clonedCanvas.width = canvas.width;
clonedCanvas.height = canvas.height;
clonedCanvasCtx?.drawImage(canvas, 0, 0);
return new Promise((resolve) => {
// Create a full-sized, full-quality blob from the original canvas.
canvas.toBlob(
(blob) => {
const url = blob ? URL.createObjectURL(blob) : canvas.toDataURL();
const image = new ImageManipulatorImageRef(url, clonedCanvas);
resolve(image);
},
// Use PNG format so the result is of the best quality.
// If you need another format, see `saveAsync` function on the image ref.
'image/png'
);
});
}
private addTask(
task: (canvas: HTMLCanvasElement) => HTMLCanvasElement | Promise<HTMLCanvasElement>
): ImageManipulatorContext {
this.currentTask = this.currentTask.then((canvas) => {
return task(canvas);
});
return this;
}
}
@@ -0,0 +1,48 @@
import { SharedRef } from 'expo';
import { ImageResult, SaveFormat, SaveOptions } from '../ImageManipulator.types';
import { blobToBase64String } from './utils.web';
export default class ImageManipulatorImageRef extends SharedRef<'image'> {
readonly nativeRefType: string = 'image';
readonly uri: string;
readonly canvas: HTMLCanvasElement;
constructor(uri: string, canvas: HTMLCanvasElement) {
super();
this.uri = uri;
this.canvas = canvas;
}
get width() {
return this.canvas.width;
}
get height() {
return this.canvas.height;
}
async saveAsync(options: SaveOptions = { base64: false }): Promise<ImageResult> {
return new Promise((resolve, reject) => {
this.canvas.toBlob(
async (blob) => {
if (!blob) {
return reject(new Error(`Unable to save image: ${this.uri}`));
}
const base64 = options.base64 ? await blobToBase64String(blob) : undefined;
const uri = URL.createObjectURL(blob);
resolve({
uri,
width: this.width,
height: this.height,
base64,
});
},
`image/${options.format ?? SaveFormat.JPEG}`,
options.compress
);
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { CodedError } from 'expo-modules-core';
import { ActionCrop } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value));
export default (canvas: HTMLCanvasElement, options: ActionCrop['crop']) => {
// ensure values are defined.
let { originX = 0, originY = 0, width = 0, height = 0 } = options;
// lock within bounds.
width = clamp(width, canvas.width);
height = clamp(height, canvas.height);
originX = clamp(originX, canvas.width);
originY = clamp(originY, canvas.height);
// lock sum of crop.
width = Math.min(originX + width, canvas.width) - originX;
height = Math.min(originY + height, canvas.height) - originY;
if (width === 0 || height === 0) {
throw new CodedError(
'ERR_IMAGE_MANIPULATOR_CROP',
'Crop size must be greater than 0: ' + JSON.stringify(options, null, 2)
);
}
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const context = getContext(result);
context.drawImage(canvas, originX, originY, width, height, 0, 0, width, height);
return result;
};
@@ -0,0 +1,43 @@
import { CodedError } from 'expo-modules-core';
import { ActionExtent } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
export default (canvas: HTMLCanvasElement, options: ActionExtent['extent']) => {
// ensure values are defined.
const { backgroundColor = null, originX = 0, originY = 0, width = 0, height = 0 } = options;
if (width === 0 || height === 0) {
throw new CodedError(
'ERR_IMAGE_MANIPULATOR_EXTENT',
'Extent size must be greater than 0: ' + JSON.stringify(options, null, 2)
);
}
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const sx = originX < 0 ? 0 : originX;
const sy = originY < 0 ? 0 : originY;
const sw =
originX < 0 ? Math.min(canvas.width, width + originX) : Math.min(canvas.width - originX, width);
const sh =
originY < 0
? Math.min(canvas.height, height + originY)
: Math.min(canvas.height - originY, height);
const dx = originX < 0 ? -originX : 0;
const dy = originY < 0 ? -originY : 0;
const context = getContext(result);
if (backgroundColor != null) {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
}
context.drawImage(canvas, sx, sy, sw, sh, dx, dy, sw, sh);
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { ActionFlip, FlipType } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
export default (canvas: HTMLCanvasElement, flip: ActionFlip['flip']) => {
const xFlip = flip === FlipType.Horizontal;
const yFlip = flip === FlipType.Vertical;
const result = document.createElement('canvas');
result.width = canvas.width;
result.height = canvas.height;
const context = getContext(result);
// Set the origin to the center of the image
context.translate(canvas.width / 2, canvas.height / 2);
// Flip/flop the canvas
const xScale = xFlip ? -1 : 1;
const yScale = yFlip ? -1 : 1;
context.scale(xScale, yScale);
// Draw the image
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
return result;
};
+119
View File
@@ -0,0 +1,119 @@
import { ActionResize } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
/**
* Hermite resize - fast image resize/resample using Hermite filter. 1 cpu version!
* https://stackoverflow.com/a/18320662/4047926
*
* @param {HTMLCanvasElement} canvas
* @param {int} width
* @param {int} height
* @param {boolean} resizeCanvas if true, canvas will be resized. Optional.
*/
function resampleSingle(
canvas: HTMLCanvasElement,
width: number,
height: number,
resizeCanvas: boolean = false
): HTMLCanvasElement {
const result = document.createElement('canvas');
result.width = canvas.width;
result.height = canvas.height;
const widthSource = canvas.width;
const heightSource = canvas.height;
width = Math.round(width);
height = Math.round(height);
const wRatio = widthSource / width;
const hRatio = heightSource / height;
const wRatioHalf = Math.ceil(wRatio / 2);
const hRatioHalf = Math.ceil(hRatio / 2);
const ctx = getContext(canvas);
const img = ctx.getImageData(0, 0, widthSource, heightSource);
const img2 = ctx.createImageData(width, height);
const data = img.data;
const data2 = img2.data;
for (let j = 0; j < height; j++) {
for (let i = 0; i < width; i++) {
const x2 = (i + j * width) * 4;
let weight = 0;
let weights = 0;
let weightsAlpha = 0;
let gx_r = 0;
let gx_g = 0;
let gx_b = 0;
let gx_a = 0;
const yCenter = (j + 0.5) * hRatio;
const yy_start = Math.floor(j * hRatio);
const yy_stop = Math.ceil((j + 1) * hRatio);
for (let yy = yy_start; yy < yy_stop; yy++) {
const dy = Math.abs(yCenter - (yy + 0.5)) / hRatioHalf;
const center_x = (i + 0.5) * wRatio;
const w0 = dy * dy; //pre-calc part of w
const xx_start = Math.floor(i * wRatio);
const xx_stop = Math.ceil((i + 1) * wRatio);
for (let xx = xx_start; xx < xx_stop; xx++) {
const dx = Math.abs(center_x - (xx + 0.5)) / wRatioHalf;
const w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
//pixel too far
continue;
}
//hermite filter
weight = 2 * w * w * w - 3 * w * w + 1;
const xPosition = 4 * (xx + yy * widthSource);
//alpha
gx_a += weight * data[xPosition + 3];
weightsAlpha += weight;
//colors
if (data[xPosition + 3] < 255) {
weight = (weight * data[xPosition + 3]) / 250;
}
gx_r += weight * data[xPosition];
gx_g += weight * data[xPosition + 1];
gx_b += weight * data[xPosition + 2];
weights += weight;
}
}
data2[x2] = gx_r / weights;
data2[x2 + 1] = gx_g / weights;
data2[x2 + 2] = gx_b / weights;
data2[x2 + 3] = gx_a / weightsAlpha;
}
}
//resize canvas
if (resizeCanvas) {
result.width = width;
result.height = height;
}
//draw
const context = getContext(result);
context.putImageData(img2, 0, 0);
return result;
}
export default (canvas: HTMLCanvasElement, { width, height }: ActionResize['resize']) => {
const imageRatio = canvas.width / canvas.height;
let requestedWidth: number = 0;
let requestedHeight: number = 0;
if (width !== undefined) {
requestedWidth = width;
requestedHeight = requestedWidth / imageRatio;
}
if (height !== undefined) {
requestedHeight = height;
if (requestedWidth === 0) {
requestedWidth = requestedHeight * imageRatio;
}
}
return resampleSingle(canvas, requestedWidth, requestedHeight, true);
};
@@ -0,0 +1,41 @@
import { ActionRotate } from '../../ImageManipulator.types';
import { getContext } from '../utils.web';
function sizeFromAngle(
width: number,
height: number,
angle: number
): { width: number; height: number } {
const radians = (angle * Math.PI) / 180;
let c = Math.cos(radians);
let s = Math.sin(radians);
if (s < 0) {
s = -s;
}
if (c < 0) {
c = -c;
}
return { width: height * s + width * c, height: height * c + width * s };
}
export default (canvas: HTMLCanvasElement, degrees: ActionRotate['rotate']) => {
const { width, height } = sizeFromAngle(canvas.width, canvas.height, degrees);
const result = document.createElement('canvas');
result.width = width;
result.height = height;
const context = getContext(result);
// Set the origin to the center of the image
context.translate(result.width / 2, result.height / 2);
// Rotate the canvas around the origin
const radians = (degrees * Math.PI) / 180;
context.rotate(radians);
// Draw the image
context.drawImage(canvas, -canvas.width / 2, -canvas.height / 2, canvas.width, canvas.height);
return result;
};
+5
View File
@@ -0,0 +1,5 @@
export { default as crop } from './CropAction.web';
export { default as extent } from './ExtentAction.web';
export { default as flip } from './FlipAction.web';
export { default as resize } from './ResizeAction.web';
export { default as rotate } from './RotateAction.web';
+39
View File
@@ -0,0 +1,39 @@
import { CodedError } from 'expo-modules-core';
export function getContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new CodedError('ERR_IMAGE_MANIPULATOR', 'Failed to create canvas context');
}
return ctx;
}
export async function blobToBase64String(blob: Blob): Promise<string> {
const dataURL = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () =>
reject(new Error(`Unable to convert blob to base64 string: ${reader.error}`));
reader.readAsDataURL(blob);
});
return dataURL.replace(/^data:image\/\w+;base64,/, '');
}
export function loadImageAsync(uri: string): Promise<HTMLCanvasElement> {
return new Promise((resolve, reject) => {
const imageSource = new Image();
imageSource.crossOrigin = 'anonymous';
const canvas = document.createElement('canvas');
imageSource.onload = () => {
canvas.width = imageSource.naturalWidth;
canvas.height = imageSource.naturalHeight;
const context = getContext(canvas);
context.drawImage(imageSource, 0, 0, imageSource.naturalWidth, imageSource.naturalHeight);
resolve(canvas);
};
imageSource.onerror = () => reject(canvas);
imageSource.src = uri;
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build",
"emitDeclarationOnly": true
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
}