diff --git a/lib/src/darwin/native.dart b/lib/src/darwin/native.dart index f05eb72..a96429c 100644 --- a/lib/src/darwin/native.dart +++ b/lib/src/darwin/native.dart @@ -19,10 +19,13 @@ import 'package:platform_image_converter/src/output_resize.dart'; /// - 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 -/// - `CGImageSourceCreateImageAtIndex`: Extract CGImage +/// - `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 @@ -48,6 +51,8 @@ final class ImageConverterDarwin implements ImageConverterPlatform { CFDataRef? cfData; CGImageSourceRef? imageSource; CGImageRef? originalImage; + // Orientation-corrected image (owned, non-null only when correction applied). + CGImageRef? orientedImage; CGImageRef? imageToEncode; CFMutableDataRef? outputData; CGImageDestinationRef? destination; @@ -77,17 +82,35 @@ final class ImageConverterDarwin implements ImageConverterPlatform { throw const ImageDecodingException(); } - final originalWidth = CGImageGetWidth(originalImage); - final originalHeight = CGImageGetHeight(originalImage); + // 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 = originalImage; + imageToEncode = baseImage; } else { - imageToEncode = _resizeImage(originalImage, newWidth, newHeight); + imageToEncode = _resizeImage(baseImage, newWidth, newHeight); } if (imageToEncode == nullptr) { @@ -161,9 +184,16 @@ final class ImageConverterDarwin implements ImageConverterPlatform { if (inputPtr != null) calloc.free(inputPtr); if (cfData != null) CFRelease(cfData.cast()); if (imageSource != null) CFRelease(imageSource.cast()); - if (imageToEncode != null && imageToEncode != originalImage) { + // 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()); @@ -269,4 +299,84 @@ final class ImageConverterDarwin implements ImageConverterPlatform { } } } + + /// 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); + + // kCGImageSourceCreateThumbnailFromImageIfAbsent = kCFBooleanTrue + keys[0] = kCGImageSourceCreateThumbnailFromImageIfAbsent.cast(); + values[0] = true.toNSNumber().ref.retainAndAutorelease().cast(); + + // kCGImageSourceCreateThumbnailWithTransform = kCFBooleanTrue + // This is the key that bakes the EXIF rotation into pixel data. + keys[1] = kCGImageSourceCreateThumbnailWithTransform.cast(); + values[1] = true.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()); + } + }); + } }