build: compile release APK and update dependencies (expo-image-manipulator/loader)
This commit is contained in:
+37
@@ -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
|
||||
+79
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
// Copyright 2024-present 650 Industries. All rights reserved.
|
||||
|
||||
internal protocol ImageTransformer {
|
||||
func transform(image: UIImage) async throws -> UIImage
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+34
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+30
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user