diff --git a/TRIAGE.md b/TRIAGE.md index f18ad50..4fe4bd5 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -13,6 +13,7 @@ Legend: | Issue | Triage | Notes | | --- | --- | --- | +| #414 | real, fixed here | `Image.compress` decoded at full native resolution before resizing, which OOM-crashes lower-RAM devices on batches of large photos. iOS now decodes downsampled and upright in one ImageIO step; Android manual mode (file and base64) uses `inSampleSize`. Both fit inside `maxWidth`/`maxHeight` without upscaling. | | #428 | real, fixed here | Android `Video.compress` rejected with the bare `CodecException` message, which is usually empty, so every codec failure read the same. This branch appends `CodecException.diagnosticInfo` (e.g. `android.media.MediaCodec.error_neg_12` = `NO_MEMORY`) to the rejection message. | | #423 | real, fixed here | Android `Video.compress` rejects with `NO_MEMORY` on Samsung Exynos encoders (`c2.exynos.h264.encoder`) at higher resolutions (e.g. 1080p manual mode). #399 set `KEY_PRIORITY = 0` + `KEY_OPERATING_RATE = Short.MAX_VALUE`; Exynos accepts them in `configure()` but fails `start()` ("real-time(32767 fps) is not supported"), which the `prepareEncoder` fallback doesn't cover. Small `auto` outputs (360×640) still fit the realtime budget. This branch removes both keys. Verified on a Galaxy A56 (Exynos 1580): 1080p manual fails with the keys and succeeds without them (94.1 MB → 32.0 MB). | | #406 | real, fixed here | Nitro module not linked in Android **release** builds (works in debug). R8 strips the `HybridCompressor` implementation, which is instantiated from C++/JNI by class name (`NitroCompressorOnLoad.cpp` `kJavaDescriptor`) and was the only Nitro class lacking keep protection — the generated spec has `@DoNotStrip @Keep`, but the hand-written impl didn't. This branch annotates `HybridCompressor` with `@DoNotStrip @Keep` (the same convention nitrogen and other Nitro modules like react-native-mmkv use), so no consumer ProGuard rules are needed. Verified with a minified release build: without the annotation R8 lists the class in `usage.txt` (removed); with it, the class + no-arg constructor are kept in `mapping.txt`. | @@ -102,3 +103,4 @@ These should be closed upstream unless a current repro still exists on the lates - Android: rewrite `NitroPromiseAdapter` in Java so the Nitro module compiles across all React Native versions regardless of `Promise.reject`'s `code` nullability (#404) - Android: drop `KEY_PRIORITY`/`KEY_OPERATING_RATE` from the encoder format so Exynos H.264 encoders don't fail `start()` with `NO_MEMORY` (#423) - Android: name the codec error (`CodecException.diagnosticInfo`) when video compression fails (#428) +- Android/iOS: downsample while decoding in `Image.compress` to avoid OOM on large photos, and fit manual output inside both bounds without upscaling (#414) diff --git a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt index f5a2241..85a75e5 100644 --- a/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt +++ b/android/src/main/java/com/reactnativecompressor/Image/ImageCompressor.kt @@ -38,25 +38,8 @@ object ImageCompressor { fun findActualSize(image: Bitmap, maxWidth: Int, maxHeight: Int): ImageSize { val width = image.width.toFloat() val height = image.height.toFloat() - if (width > height) { - val newHeight = Math.round(height / (width / maxWidth)) - val scale = newHeight / height - return ImageSize(maxWidth, newHeight, scale) - } - val newWidth = Math.round(width / (height / maxHeight)) - val scale = newWidth / width - return ImageSize(newWidth, maxHeight, scale) - } - - fun decodeImage(value: String?): Bitmap { - val data = Base64.decode(value, Base64.DEFAULT) - return BitmapFactory.decodeByteArray(data, 0, data.size) - } - - fun loadImage(value: String?): Bitmap { - val uri = Uri.parse(value) - val filePath = uri.path - return BitmapFactory.decodeFile(filePath) + val scale = minOf(1f, maxWidth / width, maxHeight / height) + return ImageSize(Math.round(width * scale), Math.round(height * scale), scale) } /** @@ -153,21 +136,78 @@ object ImageCompressor { return stream } + /** + * Decodes with downsampling via inSampleSize (the same technique + * autoCompressImage() already uses below) instead of a full decode, which + * for a high-resolution photo on a low-RAM device is a common OOM cause. + * BitmapFactory returns null rather than throwing on undecodable input. + */ + private fun decodeDownsampled(maxWidth: Int, maxHeight: Int, decode: (BitmapFactory.Options) -> Bitmap?): Bitmap { + // A zero bound makes sampleSizeToFit()'s scale 0, so its loop would never end. + require(maxWidth > 0 && maxHeight > 0) { "maxWidth and maxHeight must be greater than 0" } + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + decode(options) + options.inSampleSize = sampleSizeToFit(options.outWidth, options.outHeight, maxWidth, maxHeight) + options.inJustDecodeBounds = false + return decode(options) ?: throw IllegalArgumentException("Unable to decode image") + } + + /** + * Largest power-of-2 inSampleSize (BitmapFactory rounds down to one anyway) + * that still decodes at least the size fitted inside maxWidth x maxHeight, + * so resize() only ever scales down. calculateInSampleSize() rounds its + * ratios and can undershoot, e.g. 2000x2000 into 1280x1280 decodes 1000x1000. + */ + private fun sampleSizeToFit(width: Int, height: Int, maxWidth: Int, maxHeight: Int): Int { + val scale = minOf(maxWidth / width.toFloat(), maxHeight / height.toFloat()) + var inSampleSize = 1 + while (inSampleSize * 2 * scale <= 1f) { + inSampleSize *= 2 + } + return inSampleSize + } + + private fun isTransposed(filePath: String?): Boolean { + if (filePath == null) return false + return try { + when (ExifInterface(filePath).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_ROTATE_270 -> true + else -> false + } + } catch (e: IOException) { + false + } + } + + fun decodeImageDownsampled(value: String?, maxWidth: Int, maxHeight: Int): Bitmap { + val data = Base64.decode(value, Base64.DEFAULT) + return decodeDownsampled(maxWidth, maxHeight) { BitmapFactory.decodeByteArray(data, 0, data.size, it) } + } + + fun loadImageDownsampled(imagePath: String?, maxWidth: Int, maxHeight: Int): Bitmap { + val filePath = Uri.parse(imagePath).path + // The bounds pass measures the stored, unrotated size; resize() runs after + // EXIF rotation, so a 90° rotation swaps which edge each limit applies to. + val rotated = isTransposed(filePath) + return decodeDownsampled(if (rotated) maxHeight else maxWidth, if (rotated) maxWidth else maxHeight) { BitmapFactory.decodeFile(filePath, it) } + } + fun manualCompressImage(imagePath: String?, options: ImageCompressorOptions, reactContext: ReactApplicationContext?): String? { - val image = if (options.input === ImageCompressorOptions.InputType.base64) decodeImage(imagePath) else loadImage(imagePath) - val resizedImage = resize(image, options.maxWidth, options.maxHeight) + val image = if (options.input === ImageCompressorOptions.InputType.base64) decodeImageDownsampled(imagePath, options.maxWidth, options.maxHeight) else loadImageDownsampled(imagePath, options.maxWidth, options.maxHeight) val isBase64 = options.returnableOutputType === ImageCompressorOptions.ReturnableOutputType.base64 val uri = Uri.parse(imagePath) val imagePathNew = uri.path - var scaledBitmap: Bitmap? = correctImageOrientation(resizedImage, imagePathNew) + // Rotate first: a 90° EXIF rotation swaps which edge maxWidth/maxHeight apply to. + val scaledBitmap = resize(correctImageOrientation(image, imagePathNew)!!, options.maxWidth, options.maxHeight) val imageDataByteArrayOutputStream = compress(scaledBitmap, options.output, options.quality, options.disablePngTransparency) - val compressedImagePath = encodeImage(imageDataByteArrayOutputStream, isBase64, options.output.toString(), imagePath, reactContext) - if (isCompressedSizeLessThanActualFile(imagePath!!, compressedImagePath)) { - return compressedImagePath - } else { - MediaCache.deleteFile(compressedImagePath!!) - return slashifyFilePath(imagePath) - } + // Unlike autoCompressImage(), don't fall back to the original file when + // the compressed output isn't smaller — manualCompressImage() must + // always return a file in the requested output format (e.g. a caller + // converting HEIC/HEIF to JPEG needs that guarantee regardless of the + // resulting file size). + return encodeImage(imageDataByteArrayOutputStream, isBase64, options.output.toString(), imagePath, reactContext) } fun isCompressedSizeLessThanActualFile(sourceFileUrl: String,compressedFileUrl: String?): Boolean { diff --git a/harness/native-compressor.harness.ts b/harness/native-compressor.harness.ts index 32e39a3..6f55de1 100644 --- a/harness/native-compressor.harness.ts +++ b/harness/native-compressor.harness.ts @@ -142,6 +142,18 @@ describe('react-native-compressor native harness', () => { await expect(getFileSize(output)).resolves.toMatch(/^\d+$/); }); + it('fits inside both bounds without upscaling a small image', async () => { + const output = await Image.compress(imageUri, { + compressionMethod: 'manual', + maxWidth: 1000, + maxHeight: 500, + }); + + const metadata = await getImageMetaData(output); + expect(metadata.ImageWidth).toBe(16); + expect(metadata.ImageHeight).toBe(16); + }); + it('reads metadata from a real video fixture through the exported metadata API', async () => { const metadata = await getVideoMetaData(videoUri); expect(metadata.width).toBeGreaterThan(0); diff --git a/ios/Image/ImageCompressor.swift b/ios/Image/ImageCompressor.swift index 2bcc790..3b70edb 100644 --- a/ios/Image/ImageCompressor.swift +++ b/ios/Image/ImageCompressor.swift @@ -1,7 +1,7 @@ -import Accelerate import CoreGraphics import Photos import Foundation +import ImageIO import MobileCoreServices @@ -13,106 +13,11 @@ private enum CompressorResizeMode { class ImageCompressor { - static func findTargetSize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> CGSize { - let width = image.size.width - let height = image.size.height - - if width > height { - let newHeight = height / (width / CGFloat(maxWidth)) - return CGSize(width: CGFloat(maxWidth), height: newHeight) - } - - let newWidth = width / (height / CGFloat(maxHeight)) - return CGSize(width: newWidth, height: CGFloat(maxHeight)) - } - - - static func decodeImage(_ value: String) -> UIImage? { - if let data = Data(base64Encoded: value, options: .ignoreUnknownCharacters) { - return UIImage(data: data) - } - return nil - } - - - static func loadImage(_ path: String) -> UIImage? { - var image: UIImage? - - if path.hasPrefix("data:") || path.hasPrefix("file:") { - if let imageUrl = URL(string: path), let imageData = try? Data(contentsOf: imageUrl) { - image = UIImage(data: imageData) - } - } else { - image = UIImage(contentsOfFile: path) - } - - return image - } - - - static func manualResize(_ image: UIImage, maxWidth: Int, maxHeight: Int) -> UIImage { - let targetSize = findTargetSize(image, maxWidth: maxWidth, maxHeight: maxHeight) - - if let cgImage = image.cgImage { - let sourceWidth = Int(image.size.width) - let sourceHeight = Int(image.size.height) - let targetWidth = Int(targetSize.width) - let targetHeight = Int(targetSize.height) - let bytesPerPixel = 4 - let sourceBytesPerRow = sourceWidth * bytesPerPixel - let targetBytesPerRow = targetWidth * bytesPerPixel - let bitsPerComponent = 8 - - let colorSpace = CGColorSpaceCreateDeviceRGB() - - var sourceData = [UInt8](repeating: 0, count: sourceHeight * sourceBytesPerRow) - let context = CGContext(data: &sourceData, - width: Int(sourceWidth), - height: Int(sourceHeight), - bitsPerComponent: bitsPerComponent, - bytesPerRow: sourceBytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) - - context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: sourceWidth, height: sourceHeight)) - - - var targetData = [UInt8](repeating: 0, count: targetWidth * targetHeight * bytesPerPixel) - - var srcBuffer = vImage_Buffer(data: &sourceData, height: vImagePixelCount(sourceHeight), width: vImagePixelCount(sourceWidth), rowBytes: sourceBytesPerRow) - var targetBuffer = vImage_Buffer(data: &targetData, height: vImagePixelCount(targetHeight), width: vImagePixelCount(targetWidth), rowBytes: targetBytesPerRow) - - let error = vImageScale_ARGB8888(&srcBuffer, &targetBuffer, nil, vImage_Flags(kvImageHighQualityResampling)) - - if error != kvImageNoError { - free(&targetData) - let exception = NSException(name: NSExceptionName(rawValue: "drawing_error"), reason: "Problem while rendering your image", userInfo: nil) - exception.raise() - } - - let targetContext = CGContext(data: &targetData, - width: targetWidth, - height: targetHeight, - bitsPerComponent: bitsPerComponent, - bytesPerRow: targetBytesPerRow, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) - - let targetRef = targetContext?.makeImage() - let resizedImage = UIImage(cgImage: targetRef!) - - - return resizedImage - } - return UIImage() - } - static func isCompressedSizeLessThanActualFile(sourceFileUrl: String?, sourceData: Data?, compressedFileUrl: String)-> Bool { var sourcefileSize: Int = 0 if let _sourceFileUrl = sourceFileUrl { - let sourceVideoURL = URL(string: _sourceFileUrl) - sourcefileSize = Utils.getfileSizeInBytes(forURL: sourceVideoURL!) + sourcefileSize = Utils.getfileSizeInBytes(forURL: ImageCompressor.fileURL(_sourceFileUrl)) } if let _sourceData = sourceData { @@ -134,11 +39,7 @@ class ImageCompressor { } static func copyExifInfo(actualImagePath: String, image: UIImage, data: Data) -> Data { - let fileURL = URL(string: actualImagePath)! - let filePath = fileURL.path - - let url = URL(fileURLWithPath: filePath) - let source = CGImageSourceCreateWithURL(url as CFURL, nil)! + let source = CGImageSourceCreateWithURL(ImageCompressor.fileURL(actualImagePath) as CFURL, nil)! let metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] let dataProvider = CGDataProvider(data: data as CFData) @@ -161,8 +62,16 @@ class ImageCompressor { } } + // Pixel data here has already been decoded/rotated to the "up" + // orientation (see decodeDownsampledImage below). Without forcing this + // tag, the merge loop above copies the ORIGINAL file's Orientation tag + // (commonly 3/6/8 for camera photos) onto an already-upright image — + // any viewer that respects EXIF orientation will rotate it a second + // time, incorrectly. + dataMetadata?[kCGImagePropertyOrientation] = 1 + let outputFormat = isPNG(data) ? kUTTypePNG : kUTTypeJPEG - + let destinationData = NSMutableData() let destination = CGImageDestinationCreateWithData(destinationData, outputFormat, 1, nil)! CGImageDestinationAddImage(destination, image.cgImage!, dataMetadata as CFDictionary?) @@ -229,92 +138,96 @@ class ImageCompressor { } - static func scaleAndRotateImage(_ image: UIImage) -> UIImage { - if image.imageOrientation == .up { - return image - } - - var transform = CGAffineTransform.identity + static func makeValidUri(_ filePath: String) -> String { + let fileWithUrl = URL(fileURLWithPath: filePath) + let absoluteUrl = fileWithUrl.deletingLastPathComponent() + let fileUrl = "file://\(absoluteUrl.path)/\(fileWithUrl.lastPathComponent)" + return fileUrl + } - switch image.imageOrientation { - case .down, .downMirrored: - transform = transform.translatedBy(x: image.size.width, y: image.size.height) - transform = transform.rotated(by: CGFloat.pi) - case .left, .leftMirrored: - transform = transform.translatedBy(x: image.size.width, y: 0) - transform = transform.rotated(by: CGFloat.pi / 2) - case .right, .rightMirrored: - transform = transform.translatedBy(x: 0, y: image.size.height) - transform = transform.rotated(by: -CGFloat.pi / 2) - default: - break - } + /// Builds a file URL from a file: URI or a bare absolute path. URL(string:) + /// returns nil on iOS < 17 for a URI that isn't percent-encoded (e.g. it + /// contains spaces), so fall back to the path with its scheme stripped. + static func fileURL(_ path: String) -> URL { + guard path.hasPrefix("file:") else { return URL(fileURLWithPath: path) } + if let url = URL(string: path) { return url } + return URL(fileURLWithPath: path.replacingOccurrences(of: "^file:/*", with: "/", options: .regularExpression)) + } - 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 + /// Opens a CGImageSource for the given path — file:, data: or a bare + /// absolute path, WITHOUT decoding the image. This lets ImageIO downsample directly from the + /// source when generating a thumbnail, instead of us decoding the full + /// image into memory first. + static func makeImageSource(_ path: String) -> CGImageSource? { + if path.hasPrefix("data:") { + guard let dataURL = URL(string: path), let data = try? Data(contentsOf: dataURL) else { return nil } + return CGImageSourceCreateWithData(data as CFData, nil) } + return CGImageSourceCreateWithURL(ImageCompressor.fileURL(path) as CFURL, nil) + } - if let cgImage = image.cgImage, let colorSpace = cgImage.colorSpace { - guard let context = CGContext(data: nil, width: Int(image.size.width), height: Int(image.size.height), - bitsPerComponent: cgImage.bitsPerComponent, bytesPerRow: 0, - space: colorSpace, bitmapInfo: cgImage.bitmapInfo.rawValue) else { - return image + /// Decodes the image already downsampled AND orientation-corrected in a + /// single step via CGImageSourceCreateThumbnailAtIndex, instead of + /// UIImage(contentsOfFile:) followed by redrawing the FULL-resolution + /// bitmap into a new CGContext (the old path: peaks at ~2x the full + /// native-resolution RGBA buffer in memory, which is the root cause of + /// OOM crashes on lower-RAM devices when compressing large/HEIC photos). + /// kCGImageSourceCreateThumbnailWithTransform: true makes ImageIO apply + /// the EXIF orientation transform as part of decoding, so the returned + /// image is always orientation .up. + static func decodeDownsampledImage(imagePath: String?, base64: String?, input: InputType, maxWidth: Int, maxHeight: Int) -> UIImage? { + let imageSource: CGImageSource? + + switch input { + case .base64: + guard let base64Value = base64, + let data = Data(base64Encoded: base64Value, options: .ignoreUnknownCharacters) else { + return nil } + imageSource = CGImageSourceCreateWithData(data as CFData, nil) + case .uri: + guard let path = imagePath else { return nil } + imageSource = ImageCompressor.makeImageSource(path) + } - context.concatenate(transform) + guard let source = imageSource else { return nil } + + // kCGImageSourceThumbnailMaxPixelSize takes a single value for the longer + // edge, so derive it from the native size (metadata only, no decoding): + // fit inside both maxWidth and maxHeight, and never upscale. + var targetLongEdge = min(maxWidth, maxHeight) + if let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let pixelWidth = properties[kCGImagePropertyPixelWidth] as? Int, + let pixelHeight = properties[kCGImagePropertyPixelHeight] as? Int { + // EXIF orientations 5-8 swap width and height once applied. + let isTransposed = (properties[kCGImagePropertyOrientation] as? Int ?? 1) >= 5 + let width = CGFloat(isTransposed ? pixelHeight : pixelWidth) + let height = CGFloat(isTransposed ? pixelWidth : pixelHeight) + let scale = min(1, CGFloat(maxWidth) / width, CGFloat(maxHeight) / height) + targetLongEdge = max(1, Int((max(width, height) * scale).rounded())) + } - 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)) - } + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: targetLongEdge, + kCGImageSourceShouldCacheImmediately: true, + ] - if let rotatedCGImage = context.makeImage() { - return UIImage(cgImage: rotatedCGImage) - } + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else { + return nil } - return image + return UIImage(cgImage: thumbnail) } - - static func makeValidUri(_ filePath: String) -> String { - let fileWithUrl = URL(fileURLWithPath: filePath) - let absoluteUrl = fileWithUrl.deletingLastPathComponent() - let fileUrl = "file://\(absoluteUrl.path)/\(fileWithUrl.lastPathComponent)" - return fileUrl - } - - static func manualCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String { var exception: NSException? - var image: UIImage? - switch options.input { - case .base64: - if let _base64 = base64 { - image = ImageCompressor.decodeImage(_base64) - } - case .uri: - if let _imagePath = imagePath { - image = ImageCompressor.loadImage(_imagePath) - } - } - - if let _image = image { - image = ImageCompressor.scaleAndRotateImage(_image) + if let image = ImageCompressor.decodeDownsampledImage(imagePath: imagePath, base64: base64, input: options.input, maxWidth: options.maxWidth, maxHeight: options.maxHeight) { let outputExtension = ImageCompressorOptions.getOutputInString(options.output) - let resizedImage = ImageCompressor.manualResize(image!, maxWidth: options.maxWidth, maxHeight: options.maxHeight) let isBase64 = options.returnableOutputType == .rbase64 - return ImageCompressor.manualCompress(resizedImage, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64,disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath) + return ImageCompressor.manualCompress(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, actualImagePath: imagePath) } else { exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil) exception?.raise() @@ -323,62 +236,19 @@ class ImageCompressor { return "" } - + static func autoCompressHandler(imagePath: String?, base64: String?, options: ImageCompressorOptions) -> String { var exception: NSException? - var image: UIImage? - - switch options.input { - case .base64: - if let _base64 = base64 { - image = ImageCompressor.decodeImage(_base64) - } - case .uri: - if let _imagePath = imagePath { - image = ImageCompressor.loadImage(_imagePath) - } - } - - if var image = image { - image = ImageCompressor.scaleAndRotateImage(image) + + if let image = ImageCompressor.decodeDownsampledImage(imagePath: imagePath, base64: base64, input: options.input, maxWidth: options.maxWidth, maxHeight: options.maxHeight) { let outputExtension = ImageCompressorOptions.getOutputInString(options.output) - - var actualHeight = image.size.height - var actualWidth = image.size.width - let maxHeight: CGFloat = CGFloat(options.maxHeight) - let maxWidth: CGFloat = CGFloat(options.maxWidth) - var imgRatio = actualWidth / actualHeight - let maxRatio = maxWidth / maxHeight - let compressionQuality: CGFloat = CGFloat(options.quality) - - if actualHeight > maxHeight || actualWidth > maxWidth { - if imgRatio < maxRatio { - imgRatio = maxHeight / actualHeight - actualWidth = imgRatio * actualWidth - actualHeight = maxHeight - } else if imgRatio > maxRatio { - imgRatio = maxWidth / actualWidth - actualHeight = imgRatio * actualHeight - actualWidth = maxWidth - } else { - actualHeight = maxHeight - actualWidth = maxWidth - } - } - - let rect = CGRect(x: 0.0, y: 0.0, width: actualWidth, height: actualHeight) - UIGraphicsBeginImageContext(rect.size) - image.draw(in: rect) let isBase64 = options.returnableOutputType == .rbase64 - - if let img = UIGraphicsGetImageFromCurrentImageContext() { - return writeImage(img, output: options.output.rawValue, quality: Float(compressionQuality), outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath) - } + return ImageCompressor.writeImage(image, output: options.output.rawValue, quality: options.quality, outputExtension: outputExtension, isBase64: isBase64, disablePngTransparency: options.disablePngTransparency, isEnableAutoCompress: true, actualImagePath: imagePath) } else { exception = NSException(name: NSExceptionName(rawValue: "unsupported_value"), reason: "Unsupported value type.", userInfo: nil) exception?.raise() } - + return "" }