import 'dart:ffi'; import 'dart:typed_data'; import 'package:ffi/ffi.dart'; import 'package:objective_c/objective_c.dart'; import 'package:platform_image_converter/src/darwin/bindings.g.dart'; import 'package:platform_image_converter/src/darwin/orientation_bindings.dart'; import 'package:platform_image_converter/src/image_conversion_exception.dart'; import 'package:platform_image_converter/src/image_converter_platform_interface.dart'; import 'package:platform_image_converter/src/output_format.dart'; import 'package:platform_image_converter/src/output_resize.dart'; /// iOS/macOS image converter using ImageIO framework. /// /// Implements image conversion for iOS 14+ and macOS 10.15+ platforms using /// the native ImageIO framework through FFI bindings. /// /// **Features:** /// - Supports all major image formats (JPEG, PNG, HEIC, WebP, etc.) /// - Uses CoreFoundation and CoreGraphics for efficient processing /// - Memory-safe with proper resource cleanup /// - Applies EXIF orientation correction: bakes rotation/flip into pixels so /// output images display upright regardless of EXIF tag presence. /// /// **API Stack:** /// - `CGImageSourceCreateWithData`: Decode input image /// - `CGImageSourceCopyPropertiesAtIndex`: Read EXIF orientation tag /// - `CGImageSourceCreateThumbnailAtIndex`: Create orientation-corrected image /// - `CGBitmapContextCreate`: Create a canvas for resizing /// - `CGContextDrawImage`: Draw and scale the image /// - `CGBitmapContextCreateImage`: Extract resized CGImage /// - `CGImageDestinationCreateWithData`: Create output stream /// - `CGImageDestinationAddImage`: Add image with encoding options /// - `CGImageDestinationFinalize`: Complete encoding /// /// **Performance:** /// - Direct FFI calls with minimal overhead /// - In-memory processing /// - Adjustable JPEG/WebP quality for size optimization final class ImageConverterDarwin implements ImageConverterPlatform { const ImageConverterDarwin(); @override Uint8List convert({ required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, ResizeMode resizeMode = const OriginalResizeMode(), }) { Pointer? inputPtr; CFDataRef? cfData; CGImageSourceRef? imageSource; CGImageRef? originalImage; // Orientation-corrected image (owned, non-null only when correction applied). CGImageRef? orientedImage; CGImageRef? imageToEncode; CFMutableDataRef? outputData; CGImageDestinationRef? destination; CFDictionaryRef? properties; try { inputPtr = calloc(inputData.length); inputPtr.asTypedList(inputData.length).setAll(0, inputData); cfData = CFDataCreate( kCFAllocatorDefault, inputPtr.cast(), inputData.length, ); if (cfData == nullptr) { throw const ImageConversionException( 'Failed to create CFData from input data.', ); } imageSource = CGImageSourceCreateWithData(cfData, nullptr); if (imageSource == nullptr) { throw const ImageDecodingException('Invalid image data.'); } originalImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nullptr); if (originalImage == nullptr) { throw const ImageDecodingException(); } // CGImageSourceCreateImageAtIndex returns raw sensor pixels without // applying the EXIF Orientation tag. iPhone photos captured in portrait // are stored with orientation=6 (Right/90° CW), causing the decoded // pixels to appear rotated. We correct this by reading the orientation // and, when it is not Up (1), using CGImageSourceCreateThumbnailAtIndex // with kCGImageSourceCreateThumbnailWithTransform=true, which bakes the // rotation/flip into the pixel data before we encode. final sourceOrientation = _readOrientationFromSource(imageSource); if (sourceOrientation != 1) { final srcW = CGImageGetWidth(originalImage); final srcH = CGImageGetHeight(originalImage); final maxEdge = srcW > srcH ? srcW : srcH; orientedImage = _createOrientedImage(imageSource, maxEdge); } // Use the orientation-corrected image as the base for all further ops. final baseImage = orientedImage ?? originalImage; final originalWidth = CGImageGetWidth(baseImage); final originalHeight = CGImageGetHeight(baseImage); final (newWidth, newHeight) = resizeMode.calculateSize( originalWidth, originalHeight, ); if (newWidth == originalWidth && newHeight == originalHeight) { imageToEncode = baseImage; } else { imageToEncode = _resizeImage(baseImage, newWidth, newHeight); } if (imageToEncode == nullptr) { throw const ImageConversionException( 'Failed to prepare image for encoding. Resizing may have failed.', ); } outputData = CFDataCreateMutable(kCFAllocatorDefault, 0); if (outputData == nullptr) { throw ImageEncodingException(format, 'Failed to create output CFData.'); } final utiStr = switch (format) { // https://developer.apple.com/documentation/uniformtypeidentifiers/uttypejpeg OutputFormat.jpeg => 'public.jpeg', // https://developer.apple.com/documentation/uniformtypeidentifiers/uttypepng OutputFormat.png => 'public.png', // https://developer.apple.com/documentation/uniformtypeidentifiers/uttypeheic OutputFormat.heic => 'public.heic', // https://developer.apple.com/documentation/uniformtypeidentifiers/uttypewebp OutputFormat.webp => throw UnsupportedError( 'WebP output format is not supported on iOS/macOS via ImageIO.', ), }; final cfString = utiStr .toNSString() .ref .retainAndAutorelease() .cast(); destination = CGImageDestinationCreateWithData( outputData, cfString, 1, nullptr, ); if (destination == nullptr) { throw ImageEncodingException( format, 'Failed to create CGImageDestination.', ); } properties = _createPropertiesForFormat(format, quality); CGImageDestinationAddImage( destination, imageToEncode, properties ?? nullptr, ); final success = CGImageDestinationFinalize(destination); if (!success) { throw ImageEncodingException( format, 'Failed to finalize image encoding.', ); } final length = CFDataGetLength(outputData); final bytePtr = CFDataGetBytePtr(outputData); if (bytePtr == nullptr) { throw ImageEncodingException( format, 'Failed to get output data bytes.', ); } return Uint8List.fromList(bytePtr.cast().asTypedList(length)); } finally { if (inputPtr != null) calloc.free(inputPtr); if (cfData != null) CFRelease(cfData.cast()); if (imageSource != null) CFRelease(imageSource.cast()); // Release imageToEncode only if it is a distinct CGImage (i.e. came from // _resizeImage). Do not double-release if it was aliased to baseImage. if (imageToEncode != null && imageToEncode != orientedImage && imageToEncode != originalImage) { CFRelease(imageToEncode.cast()); } // orientedImage is an independent retained CGImageRef created by // _createOrientedImage; release it after imageToEncode. if (orientedImage != null) CFRelease(orientedImage.cast()); if (originalImage != null) CFRelease(originalImage.cast()); if (outputData != null) CFRelease(outputData.cast()); if (destination != null) CFRelease(destination.cast()); if (properties != null) CFRelease(properties.cast()); } } CFDictionaryRef? _createPropertiesForFormat( OutputFormat format, int quality, ) { if (format == OutputFormat.png || format == OutputFormat.webp) { return null; } return using((arena) { final keys = arena>(1); final values = arena>(1); keys[0] = kCGImageDestinationLossyCompressionQuality; values[0] = (quality / 100.0) .toNSNumber() .ref .retainAndAutorelease() .cast(); final keyCallBacks = arena(); final valueCallBacks = arena(); return CFDictionaryCreate( kCFAllocatorDefault, keys.cast(), values.cast(), 1, keyCallBacks, valueCallBacks, ); }); } CGImageRef _resizeImage(CGImageRef originalImage, int width, int height) { CGContextRef? context; try { final colorSpace = CGImageGetColorSpace(originalImage); if (colorSpace == nullptr) { throw const ImageConversionException( 'Failed to get color space from image for resizing.', ); } // FIX: Always use 8 bits/component for the output bitmap context. // The source may be 16bpc (e.g. 16-bit PNGs), which CGBitmapContextCreate // rejects for certain alpha/byte-order combinations, returning NULL. // Since the output is always 8-bit (JPEG/PNG for display), 8bpc is correct. const bitsPerComponent = 8; // We cannot reuse the source image's bitmapInfo because it may contain // 16-bit byte-order flags (e.g. kCGImageByteOrder16Little = 0x1000) // that are incompatible with 8bpc, causing CGBitmapContextCreate to fail. const bitmapInfo = 1; // kCGImageAlphaPremultipliedLast context = CGBitmapContextCreate( nullptr, width, height, bitsPerComponent, 0, // bytesPerRow (0 means calculate automatically) colorSpace, bitmapInfo, ); if (context == nullptr) { throw const ImageConversionException( 'Failed to create bitmap context for resizing.', ); } CGContextSetInterpolationQuality( context, CGInterpolationQuality.kCGInterpolationHigh, ); final rect = Struct.create() ..origin.x = 0 ..origin.y = 0 ..size.width = width.toDouble() ..size.height = height.toDouble(); CGContextDrawImage(context, rect, originalImage); final resizedImage = CGBitmapContextCreateImage(context); if (resizedImage == nullptr) { throw const ImageConversionException( 'Failed to create resized image from context.', ); } return resizedImage; } finally { // FIX: Check both Dart null AND FFI nullptr before releasing. // CFRelease(NULL) is a fatal error in CoreFoundation (brk #0x1). // The Dart variable `context` is non-null once assigned, but the // native pointer may be nullptr if CGBitmapContextCreate failed. if (context != null && context != nullptr) { CFRelease(context.cast()); } } } /// Reads the EXIF/CGImage orientation tag from the first image in [src]. /// /// Orientation values match CGImagePropertyOrientation: /// - 1 (Up) = normal, no correction needed /// - 6 (Right) = 90° CW — most common for portrait iPhone photos /// - 3 (Down) = 180° /// - 8 (Left) = 90° CCW /// /// Returns 1 if the tag is absent, unreadable, or the image is already upright. int _readOrientationFromSource(CGImageSourceRef src) { CFDictionaryRef? props; try { props = CGImageSourceCopyPropertiesAtIndex(src, 0, nullptr); if (props == nullptr) return 1; final orientRef = CFDictionaryGetValue( props, kCGImagePropertyOrientation.cast(), ); if (orientRef == nullptr) return 1; return using((arena) { final intPtr = arena(); // kCFNumberInt32Type = 9 final ok = CFNumberGetValue(orientRef, 9, intPtr.cast()); return ok ? intPtr.value : 1; }); } finally { if (props != null && props != nullptr) CFRelease(props.cast()); } } /// Creates a new CGImageRef with EXIF orientation baked into pixel data. /// /// Uses [CGImageSourceCreateThumbnailAtIndex] with /// `kCGImageSourceCreateThumbnailWithTransform = true`, which instructs /// ImageIO to apply the EXIF rotation/flip before returning the image. /// [maxPixelSize] should be the longest edge of the source image so the /// returned image is full resolution (or at least as large as the source). /// /// Returns a retained [CGImageRef] that the caller must [CFRelease], /// or `null` if the thumbnail could not be created. CGImageRef? _createOrientedImage(CGImageSourceRef src, int maxPixelSize) { return using((arena) { final keys = arena>(3); final values = arena>(3); // kCGImageSourceCreateThumbnailFromImageAlways = kCFBooleanTrue // MUST use "Always" not "IfAbsent": iPhone HEIC files embed a tiny // ~240x320 JPEG thumbnail. "IfAbsent" returns that embedded thumbnail // instead of decoding the full image, giving very low-resolution output. keys[0] = kCGImageSourceCreateThumbnailFromImageAlways.cast(); values[0] = 1.toNSNumber().ref.retainAndAutorelease().cast(); // kCGImageSourceCreateThumbnailWithTransform = kCFBooleanTrue // This is the key that bakes the EXIF rotation into pixel data. keys[1] = kCGImageSourceCreateThumbnailWithTransform.cast(); values[1] = 1.toNSNumber().ref.retainAndAutorelease().cast(); // kCGImageSourceThumbnailMaxPixelSize = longest source edge // Setting this to the source's longest edge returns full resolution. keys[2] = kCGImageSourceThumbnailMaxPixelSize.cast(); values[2] = maxPixelSize.toNSNumber().ref.retainAndAutorelease().cast(); final keyCallbacks = arena(); final valueCallbacks = arena(); final opts = CFDictionaryCreate( kCFAllocatorDefault, keys.cast(), values.cast(), 3, keyCallbacks, valueCallbacks, ); if (opts == nullptr) return null; try { final thumb = CGImageSourceCreateThumbnailAtIndex(src, 0, opts); return (thumb == nullptr) ? null : thumb; } finally { CFRelease(opts.cast()); } }); } }