From 5d55ed4a6e6291b94d4f82b73aeaaa1c436d293a Mon Sep 17 00:00:00 2001 From: JohnE Date: Thu, 19 Feb 2026 20:24:19 -0800 Subject: [PATCH] fix: bake EXIF orientation into pixels on Darwin (orientation=6 HEIC rotation bug) --- lib/src/darwin/bindings.g.dart | 55 +++++++++++++++ lib/src/darwin/native.dart | 122 +++++++++++++++++++++++++++++++-- pubspec.yaml | 2 +- 3 files changed, 172 insertions(+), 7 deletions(-) diff --git a/lib/src/darwin/bindings.g.dart b/lib/src/darwin/bindings.g.dart index e1be9d9..d2fea58 100644 --- a/lib/src/darwin/bindings.g.dart +++ b/lib/src/darwin/bindings.g.dart @@ -130,6 +130,61 @@ external CGImageRef CGImageSourceCreateImageAtIndex( CFDictionaryRef options, ); +/// Copies the properties dictionary for the image at [index] inside [isrc]. +/// Returns a retained CFDictionaryRef — caller must CFRelease. +@ffi.Native() +external CFDictionaryRef CGImageSourceCopyPropertiesAtIndex( + CGImageSourceRef isrc, + int index, + CFDictionaryRef options, +); + +/// Creates a thumbnail for the image at [index] inside [isrc] using [options]. +/// Setting kCGImageSourceCreateThumbnailWithTransform bakes EXIF orientation. +/// Returns a retained CGImageRef — caller must CFRelease. +@ffi.Native() +external CGImageRef CGImageSourceCreateThumbnailAtIndex( + CGImageSourceRef isrc, + int index, + CFDictionaryRef options, +); + +/// Returns the value associated with [key] in [theDict], or nullptr. +@ffi.Native Function(CFDictionaryRef, ffi.Pointer)>() +external ffi.Pointer CFDictionaryGetValue( + CFDictionaryRef theDict, + ffi.Pointer key, +); + +/// Extracts the numeric value from a CFNumber into [valuePtr]. +/// [theType] 9 = kCFNumberInt32Type. +@ffi.Native, ffi.Int32, ffi.Pointer)>() +external bool CFNumberGetValue( + ffi.Pointer number, + int theType, + ffi.Pointer valuePtr, +); + +/// EXIF/CGImage orientation tag key in CGImageSource properties dictionaries. +@ffi.Native>() +external ffi.Pointer kCGImagePropertyOrientation; + +/// When kCFBooleanTrue, creates thumbnail even if none is embedded, using +/// kCGImageSourceThumbnailMaxPixelSize to determine the output dimensions. +@ffi.Native>() +external ffi.Pointer + kCGImageSourceCreateThumbnailFromImageIfAbsent; + +/// When kCFBooleanTrue, rotates/flips the thumbnail to apply the EXIF +/// orientation tag, producing correctly-oriented pixel data. +@ffi.Native>() +external ffi.Pointer kCGImageSourceCreateThumbnailWithTransform; + +/// Maximum pixel size (longest edge) for the thumbnail returned by +/// CGImageSourceCreateThumbnailAtIndex. +@ffi.Native>() +external ffi.Pointer kCGImageSourceThumbnailMaxPixelSize; + @ffi.Native>() external ffi.Pointer kCGImageDestinationLossyCompressionQuality; diff --git a/lib/src/darwin/native.dart b/lib/src/darwin/native.dart index 10d272b..eb5319f 100644 --- a/lib/src/darwin/native.dart +++ b/lib/src/darwin/native.dart @@ -18,10 +18,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 @@ -47,6 +50,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; @@ -76,17 +81,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) { @@ -160,9 +183,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()); @@ -268,4 +298,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()); + } + }); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 1af3e22..799bd3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: platform_image_converter description: "A high-performance Flutter plugin for cross-platform image format conversion using native APIs." -version: 1.0.6 +version: 1.0.7 homepage: https://github.com/koji-1009/platform_image_converter topics: - image