386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
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<Uint8>? 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<Uint8>(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<CFString>();
|
|
|
|
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<Uint8>().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<Pointer<CFString>>(1);
|
|
final values = arena<Pointer<Void>>(1);
|
|
|
|
keys[0] = kCGImageDestinationLossyCompressionQuality;
|
|
values[0] = (quality / 100.0)
|
|
.toNSNumber()
|
|
.ref
|
|
.retainAndAutorelease()
|
|
.cast<Void>();
|
|
|
|
final keyCallBacks = arena<CFDictionaryKeyCallBacks>();
|
|
final valueCallBacks = arena<CFDictionaryValueCallBacks>();
|
|
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<CGRect>()
|
|
..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<Int32>();
|
|
// 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<Pointer<Void>>(3);
|
|
final values = arena<Pointer<Void>>(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<CFDictionaryKeyCallBacks>();
|
|
final valueCallbacks = arena<CFDictionaryValueCallBacks>();
|
|
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());
|
|
}
|
|
});
|
|
}
|
|
}
|