Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
92b967bfc5 | |
|
|
32e37d2b5f | |
|
|
ca9bde7034 | |
|
|
b3d5df9477 | |
|
|
a8ab7c4217 | |
|
|
7edaf83d04 | |
|
|
5d55ed4a6e | |
|
|
82af628a19 |
|
|
@ -4,6 +4,7 @@ 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';
|
||||
|
|
@ -18,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
|
||||
|
|
@ -47,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;
|
||||
|
|
@ -76,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) {
|
||||
|
|
@ -160,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());
|
||||
|
|
@ -212,8 +243,16 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
|||
);
|
||||
}
|
||||
|
||||
final bitsPerComponent = CGImageGetBitsPerComponent(originalImage);
|
||||
final bitmapInfo = CGImageGetBitmapInfo(originalImage);
|
||||
// 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,
|
||||
|
|
@ -251,7 +290,96 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
|||
}
|
||||
return resizedImage;
|
||||
} finally {
|
||||
if (context != null) CFRelease(context.cast());
|
||||
// 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
// Hand-written FFI bindings for EXIF/CGImageSource orientation APIs.
|
||||
//
|
||||
// These supplement the auto-generated bindings.g.dart without modifying it.
|
||||
// They bind the CoreFoundation / ImageIO symbols needed to read and apply
|
||||
// the EXIF orientation tag when converting images on Darwin (iOS/macOS).
|
||||
//
|
||||
// Relevant Apple documentation:
|
||||
// - CGImageSourceCopyPropertiesAtIndex:
|
||||
// https://developer.apple.com/documentation/imageio/cgimagesourcecopypropertiesatindex
|
||||
// - CGImageSourceCreateThumbnailAtIndex:
|
||||
// https://developer.apple.com/documentation/imageio/cgimagesourcecreatethumbnailatindex
|
||||
// - kCGImagePropertyOrientation:
|
||||
// https://developer.apple.com/documentation/imageio/kcgimagepropertyorientation
|
||||
// - kCGImageSourceCreateThumbnailWithTransform:
|
||||
// https://developer.apple.com/documentation/imageio/kcgimagesourcecreatethumbnailwithtransform
|
||||
|
||||
// ignore_for_file: type=lint, non_constant_identifier_names
|
||||
import 'dart:ffi' as ffi;
|
||||
|
||||
import 'package:objective_c/objective_c.dart' as objc;
|
||||
|
||||
import 'bindings.g.dart';
|
||||
|
||||
// ─── CGImageSource properties ────────────────────────────────────────────────
|
||||
|
||||
/// Copies the properties dictionary for the image at [index] inside [isrc].
|
||||
///
|
||||
/// Returns a retained `CFDictionaryRef` — the caller must `CFRelease` it.
|
||||
/// Returns `nullptr` if the source or image is invalid.
|
||||
@ffi.Native<CFDictionaryRef Function(CGImageSourceRef, ffi.Size, CFDictionaryRef)>()
|
||||
external CFDictionaryRef CGImageSourceCopyPropertiesAtIndex(
|
||||
CGImageSourceRef isrc,
|
||||
int index,
|
||||
CFDictionaryRef options,
|
||||
);
|
||||
|
||||
/// Creates an orientation-corrected image for the entry at [index].
|
||||
///
|
||||
/// With `kCGImageSourceCreateThumbnailWithTransform = kCFBooleanTrue` in
|
||||
/// [options], ImageIO bakes the EXIF rotation and/or flip into the returned
|
||||
/// pixel data, giving correctly-oriented pixels for any orientation value.
|
||||
///
|
||||
/// Returns a retained `CGImageRef` — the caller must `CFRelease` it.
|
||||
@ffi.Native<CGImageRef Function(CGImageSourceRef, ffi.Size, CFDictionaryRef)>()
|
||||
external CGImageRef CGImageSourceCreateThumbnailAtIndex(
|
||||
CGImageSourceRef isrc,
|
||||
int index,
|
||||
CFDictionaryRef options,
|
||||
);
|
||||
|
||||
// ─── CFDictionary / CFNumber lookup ──────────────────────────────────────────
|
||||
|
||||
/// Returns the value associated with [key] in [theDict], or `nullptr`.
|
||||
///
|
||||
/// The returned pointer is not retained — do not `CFRelease` it.
|
||||
@ffi.Native<ffi.Pointer<ffi.Void> Function(CFDictionaryRef, ffi.Pointer<ffi.Void>)>()
|
||||
external ffi.Pointer<ffi.Void> CFDictionaryGetValue(
|
||||
CFDictionaryRef theDict,
|
||||
ffi.Pointer<ffi.Void> key,
|
||||
);
|
||||
|
||||
/// Extracts the numeric value from a `CFNumber` into a native Dart pointer.
|
||||
///
|
||||
/// [theType] selects the C numeric type; 9 = `kCFNumberSInt32Type` (int32).
|
||||
/// Returns `true` on success.
|
||||
@ffi.Native<ffi.Bool Function(ffi.Pointer<ffi.Void>, ffi.Int32, ffi.Pointer<ffi.Void>)>()
|
||||
external bool CFNumberGetValue(
|
||||
ffi.Pointer<ffi.Void> number,
|
||||
int theType,
|
||||
ffi.Pointer<ffi.Void> valuePtr,
|
||||
);
|
||||
|
||||
// ─── ImageIO option keys ──────────────────────────────────────────────────────
|
||||
|
||||
/// Key: EXIF/CGImage orientation tag in CGImageSource properties dicts.
|
||||
///
|
||||
/// Value is a `CFNumberRef` (int32) matching `CGImagePropertyOrientation`:
|
||||
/// 1=Up, 2=UpMirrored, 3=Down, 4=DownMirrored,
|
||||
/// 5=LeftMirrored, 6=Right, 7=RightMirrored, 8=Left.
|
||||
@ffi.Native<ffi.Pointer<objc.CFString>>()
|
||||
external ffi.Pointer<objc.CFString> kCGImagePropertyOrientation;
|
||||
|
||||
/// Key: when `kCFBooleanTrue`, creates a thumbnail from the full image even
|
||||
/// when no embedded thumbnail is present.
|
||||
@ffi.Native<ffi.Pointer<objc.CFString>>()
|
||||
external ffi.Pointer<objc.CFString> kCGImageSourceCreateThumbnailFromImageIfAbsent;
|
||||
|
||||
/// Key: when `kCFBooleanTrue`, ALWAYS creates a thumbnail from the full source
|
||||
/// image data, ignoring any embedded thumbnail.
|
||||
///
|
||||
/// **Use this instead of [kCGImageSourceCreateThumbnailFromImageIfAbsent] when
|
||||
/// full-resolution output is required.** iPhone HEIC files always contain a
|
||||
/// tiny embedded JPEG thumbnail (~240×320). Without this key, ImageIO returns
|
||||
/// the embedded thumbnail rather than decoding the full image, producing a very
|
||||
/// low-resolution output regardless of [kCGImageSourceThumbnailMaxPixelSize].
|
||||
@ffi.Native<ffi.Pointer<objc.CFString>>()
|
||||
external ffi.Pointer<objc.CFString> kCGImageSourceCreateThumbnailFromImageAlways;
|
||||
|
||||
/// Key: when `kCFBooleanTrue`, rotates/flips the thumbnail to match the EXIF
|
||||
/// orientation tag, producing pixel-correct upright image data.
|
||||
@ffi.Native<ffi.Pointer<objc.CFString>>()
|
||||
external ffi.Pointer<objc.CFString> kCGImageSourceCreateThumbnailWithTransform;
|
||||
|
||||
/// Key: maximum pixel size (longest edge) for the thumbnail.
|
||||
///
|
||||
/// Set this to the source image's longest edge to get full-resolution output
|
||||
/// from `CGImageSourceCreateThumbnailAtIndex`.
|
||||
@ffi.Native<ffi.Pointer<objc.CFString>>()
|
||||
external ffi.Pointer<objc.CFString> kCGImageSourceThumbnailMaxPixelSize;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue