diff --git a/README.md b/README.md index d5ab519..f75ff35 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # platform_image_converter -A high-performance Flutter plugin for cross-platform image format conversion using native APIs on iOS, macOS, Android, and Web. +A high-performance Flutter plugin for cross-platform image format conversion and resizing using native APIs on iOS, macOS, Android, and Web. ## Features - 🖼️ **Versatile Format Conversion**: Supports conversion between JPEG, PNG, and WebP. It also handles HEIC/HEIF, allowing conversion *from* HEIC on all supported platforms and *to* HEIC on iOS/macOS. -- ⚡ **Native Performance**: Achieves high speed by using platform-native APIs directly: ImageIO on iOS/macOS and BitmapFactory on Android. +- 📐 **High-Quality Resizing**: Resize images with different modes (`Fit`, `Exact`) while maintaining aspect ratio or targeting specific dimensions. +- ⚡ **Native Performance**: Achieves high speed by using platform-native APIs directly: `ImageIO` and `Core Graphics` on iOS/macOS, `BitmapFactory` and `Bitmap` methods on Android, and the `Canvas API` on the Web. - 🔒 **Efficient Native Interop**: Employs FFI and JNI to create a fast, type-safe bridge between Dart and native code, ensuring robust and reliable communication. ## Platform Support | Platform | Minimum Version | API Used | |----------|-----------------|----------| -| iOS | 14.0 | ImageIO (CoreFoundation, CoreGraphics) | -| macOS | 10.15 | ImageIO (CoreFoundation, CoreGraphics) | +| iOS | 14.0 | ImageIO, Core Graphics | +| macOS | 10.15 | ImageIO, Core Graphics | | Android | 7 | BitmapFactory, Bitmap compression | | Web | - | Canvas API | @@ -46,6 +47,13 @@ final jpegData = await ImageConverter.convert( quality: 90, ); +// Convert and resize an image to fit within 200x200 +final resizedData = await ImageConverter.convert( + inputData: imageData, + format: OutputFormat.png, + resizeMode: const FitResizeMode(width: 200, height: 200), +); + // Convert any format to PNG final pngData = await ImageConverter.convert( inputData: imageData, @@ -64,7 +72,7 @@ final pngData = await ImageConverter.convert( The supported output formats are defined by the `OutputFormat` enum, with platform-specific limitations: - **JPEG**: Supported on all platforms. - **PNG**: Supported on all platforms. -- **WebP**: Supported on Android only. +- **WebP**: Supported on Android and Web. - **HEIC**: Supported on iOS/macOS only. ## API Reference @@ -76,87 +84,74 @@ static Future convert({ required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async ``` **Parameters:** -- `inputData` (`Uint8List`): Raw image data to convert -- `format` (`OutputFormat`): Target image format (default: JPEG) -- `quality` (`int`): Compression quality 1-100 (default: 100, only for lossy formats) +- `inputData` (`Uint8List`): Raw image data to convert. +- `format` (`OutputFormat`): Target image format (default: JPEG). +- `quality` (`int`): Compression quality 1-100 (default: 100, only for lossy formats). +- `resizeMode` (`ResizeMode`): The resize mode to apply. Defaults to `OriginalResizeMode`, which keeps the original dimensions. -**Returns:** `Future` containing the converted image data +**Returns:** `Future` containing the converted image data. **Throws:** -- `UnsupportedError`: If the platform or format is not supported -- `Exception`: If conversion fails +- `UnsupportedError`: If the platform or format is not supported. +- `Exception`: If conversion fails. ### `OutputFormat` Enum ```dart enum OutputFormat { - /// JPEG format (.jpg, .jpeg) - /// Lossy compression, suitable for photos - jpeg, - - /// PNG format (.png) - /// Lossless compression, supports transparency - png, - - /// WebP format (.webp) - /// Modern format with superior compression (not supported on Darwin) - webp, - - /// HEIC format (.heic) - /// High Efficiency Image Format (not supported on Android) - heic, + jpeg, // .jpg, .jpeg + png, // .png + webp, // .webp + heic, // .heic } ``` +### `ResizeMode` Sealed Class + +A sealed class representing different ways to resize an image. + +- **`OriginalResizeMode()`**: Keeps the original dimensions of the image. +- **`ExactResizeMode({required int width, required int height})`**: Resizes the image to exact dimensions, possibly changing the aspect ratio. +- **`FitResizeMode({required int width, required int height})`**: Fits the image within the specified dimensions while maintaining the aspect ratio. If the image is smaller than the specified dimensions, it will not be scaled up. + + ## Implementation Details ### iOS/macOS Implementation -The iOS/macOS implementation uses the [ImageIO](https://developer.apple.com/documentation/imageio) framework via FFI bindings: +The iOS/macOS implementation uses the [ImageIO](https://developer.apple.com/documentation/imageio) and [Core Graphics](https://developer.apple.com/documentation/coregraphics) frameworks via FFI bindings: -1. **Decoding**: `CGImageSourceCreateWithData` reads input data -2. **Rendering**: `CGImageSourceCreateImageAtIndex` decodes to `CGImage` -3. **Encoding**: `CGImageDestinationCreateWithData` encodes to target format -4. **Quality**: Uses `kCGImageDestinationLossyCompressionQuality` for JPEG/WebP - -**Key Functions:** -- `CFDataCreate`: Create immutable data from input bytes -- `CGImageSourceCreateWithData`: Create image source from data -- `CGImageDestinationCreateWithData`: Create image destination -- `CGImageDestinationAddImage`: Add image to destination -- `CGImageDestinationFinalize`: Complete encoding +1. **Decoding**: `CGImageSourceCreateWithData` reads input data. +2. **Resizing**: + - `CGBitmapContextCreate` creates a new bitmap context with the target dimensions. + - `CGContextDrawImage` draws the original image into the context, scaling it in the process. `CGContextSetInterpolationQuality` is set to high for better quality. + - `CGBitmapContextCreateImage` creates a new `CGImage` from the context. +3. **Encoding**: `CGImageDestinationCreateWithData` encodes the final `CGImage` to the target format. +4. **Quality**: Uses `kCGImageDestinationLossyCompressionQuality` for JPEG/HEIC. ### Android Implementation The Android implementation uses [BitmapFactory](https://developer.android.com/reference/android/graphics/BitmapFactory) and [Bitmap.compress](https://developer.android.com/reference/android/graphics/Bitmap#compress(android.graphics.Bitmap.CompressFormat,%20int,%20java.io.OutputStream)): -1. **Decoding**: `BitmapFactory.decodeByteArray` handles all supported formats -2. **Compression**: `Bitmap.compress` encodes to target format -3. **Buffer Management**: `ByteArrayOutputStream` manages output data - -**Key Limitations:** -- HEIC can be read (input only) but cannot be written (output format not supported) -- Requires Android 9+ for full HEIC support +1. **Decoding**: `BitmapFactory.decodeByteArray` handles all supported formats. +2. **Resizing**: `Bitmap.createScaledBitmap` is used to create a new, resized bitmap from the original, with filtering enabled for smoother results. +3. **Compression**: `Bitmap.compress` encodes the final bitmap to the target format. +4. **Buffer Management**: `ByteArrayOutputStream` manages output data. ### Web Implementation The Web implementation uses the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) for image conversion: -1. **Decoding**: `HTMLImageElement` loads image data via Blob URL -2. **Rendering**: `CanvasRenderingContext2D.drawImage` renders the image to canvas -3. **Encoding**: `HTMLCanvasElement.toBlob` encodes to target format -4. **Quality**: Supports quality parameter for JPEG and WebP (0.0-1.0 scale) - -**Key Steps:** -- `Blob` and `URL.createObjectURL`: Create temporary URL from input bytes -- `HTMLImageElement.onLoad`: Wait for image to load asynchronously -- `canvas.width/height = img.width/height`: Set canvas size to match image dimensions -- `canvas.toBlob`: Convert canvas content to target format +1. **Decoding**: `HTMLImageElement` loads image data via a Blob URL. +2. **Resizing & Rendering**: `CanvasRenderingContext2D.drawImage` renders the image to a canvas with the target dimensions, effectively resizing it. +3. **Encoding**: `HTMLCanvasElement.toBlob` encodes the canvas content to the target format. +4. **Quality**: Supports quality parameter for JPEG and WebP (0.0-1.0 scale). **Key Limitations:** -- HEIC format is not supported on Web platform -- Output format depends on browser support (JPEG and PNG are universally supported, WebP is widely supported) +- HEIC format is not supported on Web platform. +- Output format depends on browser support (JPEG and PNG are universally supported, WebP is widely supported). diff --git a/example/assets/heic.heic b/example/assets/heic.heic deleted file mode 100644 index c1d8bd9..0000000 Binary files a/example/assets/heic.heic and /dev/null differ diff --git a/example/integration_test/app_test.dart b/example/integration_test/app_test.dart index f52cfc3..60b21e4 100644 --- a/example/integration_test/app_test.dart +++ b/example/integration_test/app_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:platform_image_converter/platform_image_converter.dart'; +import 'package:image/image.dart' as img; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -11,14 +12,84 @@ void main() { late Uint8List jpegData; late Uint8List pngData; late Uint8List webpData; - late Uint8List heicData; setUpAll(() async { // Load test images from assets jpegData = await _loadAssetImage('assets/jpeg.jpg'); pngData = await _loadAssetImage('assets/png.png'); webpData = await _loadAssetImage('assets/webp.webp'); - heicData = await _loadAssetImage('assets/heic.heic'); + }); + + group('Image resizing tests', () { + test('OriginalResizeMode preserves original dimensions', () async { + final originalImage = img.decodeImage(jpegData)!; + final originalWidth = originalImage.width; + final originalHeight = originalImage.height; + + final converted = await ImageConverter.convert( + inputData: jpegData, + format: OutputFormat.jpeg, + resizeMode: const OriginalResizeMode(), + ); + + final resizedImage = img.decodeImage(converted)!; + expect(resizedImage.width, equals(originalWidth)); + expect(resizedImage.height, equals(originalHeight)); + }); + + test('ExactResizeMode resizes to exact dimensions', () async { + final targetWidth = 100; + final targetHeight = 150; + + final converted = await ImageConverter.convert( + inputData: jpegData, + format: OutputFormat.jpeg, + resizeMode: ExactResizeMode(width: targetWidth, height: targetHeight), + ); + + final resizedImage = img.decodeImage(converted)!; + expect(resizedImage.width, equals(targetWidth)); + expect(resizedImage.height, equals(targetHeight)); + }); + + test('FitResizeMode maintains aspect ratio when downscaling', () async { + // Original jpeg.jpg is 1502x2000 + final targetWidth = 200; + final targetHeight = 200; + final expectedWidth = 150; + final expectedHeight = 200; + + final converted = await ImageConverter.convert( + inputData: jpegData, + format: OutputFormat.jpeg, + resizeMode: FitResizeMode(width: targetWidth, height: targetHeight), + ); + + final resizedImage = img.decodeImage(converted)!; + // Allow for small rounding differences + expect(resizedImage.width, closeTo(expectedWidth, 1)); + expect(resizedImage.height, closeTo(expectedHeight, 1)); + }); + + test('FitResizeMode does not upscale smaller images', () async { + final originalImage = img.decodeImage(jpegData)!; + final originalWidth = originalImage.width; + final originalHeight = originalImage.height; + + // Target dimensions are larger than the original + final targetWidth = originalWidth * 2; + final targetHeight = originalHeight * 2; + + final converted = await ImageConverter.convert( + inputData: jpegData, + format: OutputFormat.jpeg, + resizeMode: FitResizeMode(width: targetWidth, height: targetHeight), + ); + + final resizedImage = img.decodeImage(converted)!; + expect(resizedImage.width, equals(originalWidth)); + expect(resizedImage.height, equals(originalHeight)); + }); }); group('File size consistency tests', () { diff --git a/example/lib/main.dart b/example/lib/main.dart index 0d65f07..2ce1f83 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -29,6 +29,9 @@ class MainPage extends StatefulWidget { } class _MainPageState extends State { + final _widthController = TextEditingController(); + final _heightController = TextEditingController(); + Uint8List? _originalImage; String? _originalName; Uint8List? _convertedImage; @@ -37,6 +40,13 @@ class _MainPageState extends State { bool _isLoading = false; double _quality = 90; + @override + void dispose() { + _widthController.dispose(); + _heightController.dispose(); + super.dispose(); + } + Future _pickImage() async { final picker = ImagePicker(); final pickedFile = await picker.pickImage(source: ImageSource.gallery); @@ -55,10 +65,20 @@ class _MainPageState extends State { setState(() => _isLoading = true); final sw = Stopwatch()..start(); try { + final width = int.tryParse(_widthController.text); + final height = int.tryParse(_heightController.text); + final resizeMode = switch ((width, height)) { + (null, null) => const OriginalResizeMode(), + (final w?, final h?) => ExactResizeMode(width: w, height: h), + (final w?, null) => FitResizeMode(width: w, height: 1 << 30), + (null, final h?) => FitResizeMode(width: 1 << 30, height: h), + }; + final converted = await ImageConverter.convert( inputData: _originalImage!, format: format, quality: _quality.round(), + resizeMode: resizeMode, ); sw.stop(); _convertedImage = converted; @@ -81,8 +101,9 @@ class _MainPageState extends State { appBar: AppBar(title: const Text('platform_image_converter Demo')), body: SingleChildScrollView( padding: const .all(16), + keyboardDismissBehavior: .onDrag, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ FilledButton( onPressed: _pickImage, @@ -100,6 +121,31 @@ class _MainPageState extends State { ? null : (v) => setState(() => _quality = v), ), + Row( + children: [ + Expanded( + child: TextField( + controller: _widthController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Width', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _heightController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Height', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), if (_originalImage != null) ...[ Text('Original Image ($_originalName): '), Image.memory(_originalImage!, height: 180), @@ -143,7 +189,7 @@ class _MainPageState extends State { Column( children: [ Text('Converted ($_convertedFormat):'), - Image.memory(_convertedImage!, height: 180), + Image.memory(_convertedImage!, height: 180, fit: .contain), Text('Size: ${_convertedImage!.lengthInBytes} bytes'), if (_convertElapsedMs != null) Text('Convert time: $_convertElapsedMs ms'), diff --git a/example/pubspec.lock b/example/pubspec.lock index ef548de..6590a03 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -202,6 +210,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct dev" + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" image_picker: dependency: "direct main" description: @@ -383,6 +399,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -406,6 +430,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" process: dependency: transitive description: @@ -523,6 +555,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e01c4b7..db1dc1c 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: platform_image_converter: path: ../ - image_picker: + image_picker: ^1.0.0 dev_dependencies: flutter_test: @@ -21,6 +21,7 @@ dev_dependencies: integration_test: sdk: flutter flutter_lints: ^6.0.0 + image: ^4.0.0 flutter: uses-material-design: true diff --git a/ffigen.dart b/ffigen.dart index 3503353..db393c4 100644 --- a/ffigen.dart +++ b/ffigen.dart @@ -26,6 +26,18 @@ final config = FfiGenerator( 'CGImageDestinationCreateWithData', 'CGImageDestinationAddImage', 'CGImageDestinationFinalize', + // CGImage operations + 'CGImageGetBitsPerComponent', + 'CGImageGetBitmapInfo', + 'CGImageGetColorSpace', + 'CGImageGetWidth', + 'CGImageGetHeight', + // CGContext operations + 'CGContextDrawImage', + 'CGContextSetInterpolationQuality', + // CGBitmapContext operations + 'CGBitmapContextCreateImage', + 'CGBitmapContextCreate', // Memory management 'CFRelease', }), @@ -38,8 +50,10 @@ final config = FfiGenerator( typedefs: Typedefs.includeSet({ 'CFDataRef', 'CFDictionaryRef', + 'CGContextRef', 'CGImageRef', 'CGImageSourceRef', + 'CGColorSpaceRef', 'CFMutableDataRef', 'CGImageDestinationRef', }), diff --git a/lib/platform_image_converter.dart b/lib/platform_image_converter.dart index 3ca7a2b..e4cc89e 100644 --- a/lib/platform_image_converter.dart +++ b/lib/platform_image_converter.dart @@ -7,9 +7,11 @@ import 'package:platform_image_converter/src/android/shared.dart'; import 'package:platform_image_converter/src/darwin/shared.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'; import 'package:platform_image_converter/src/web/shared.dart'; export 'src/output_format.dart'; +export 'src/output_resize.dart'; /// Main entry point for image format conversion. /// @@ -32,6 +34,7 @@ class ImageConverter { /// - [inputData]: Raw bytes of the image to convert. /// - [format]: Target [OutputFormat]. Defaults to [OutputFormat.jpeg]. /// - [quality]: Compression quality for lossy formats (1-100). + /// - [resizeMode]: The resize mode to apply to the image. /// - [runInIsolate]: Whether to run the conversion in a separate isolate. /// Defaults to `true`. /// @@ -63,6 +66,7 @@ class ImageConverter { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), bool runInIsolate = true, }) async { if (runInIsolate) { @@ -70,6 +74,7 @@ class ImageConverter { inputData: inputData, format: format, quality: quality, + resizeMode: resizeMode, platform: defaultTargetPlatform, )); } else { @@ -78,6 +83,7 @@ class ImageConverter { inputData: inputData, format: format, quality: quality, + resizeMode: resizeMode, ); } } @@ -103,6 +109,7 @@ Future _convertInIsolate( Uint8List inputData, OutputFormat format, int quality, + ResizeMode resizeMode, TargetPlatform platform, }) request, @@ -112,5 +119,6 @@ Future _convertInIsolate( inputData: request.inputData, format: request.format, quality: request.quality, + resizeMode: request.resizeMode, ); } diff --git a/lib/src/android/native.dart b/lib/src/android/native.dart index 37c6b29..c9eaf29 100644 --- a/lib/src/android/native.dart +++ b/lib/src/android/native.dart @@ -4,6 +4,7 @@ import 'package:jni/jni.dart'; import 'package:platform_image_converter/src/android/bindings.g.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'; /// Android image converter using BitmapFactory and Bitmap compression. /// @@ -17,6 +18,7 @@ import 'package:platform_image_converter/src/output_format.dart'; /// /// **API Stack:** /// - `BitmapFactory.decodeByteArray`: Auto-detect and decode input +/// - `Bitmap.createScaledBitmap`: Resize image with filtering /// - `Bitmap.compress`: Encode to target format with quality control /// - `ByteArrayOutputStream`: Memory-based output buffer /// @@ -35,19 +37,68 @@ final class ImageConverterAndroid implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async { JByteArray? inputJBytes; - Bitmap? bitmap; + Bitmap? originalBitmap; + Bitmap? scaledBitmap; + Bitmap? bitmapToCompress; Bitmap$CompressFormat? compressFormat; ByteArrayOutputStream? outputStream; JByteArray? outputJBytes; try { inputJBytes = JByteArray.from(inputData); - bitmap = BitmapFactory.decodeByteArray(inputJBytes, 0, inputData.length); - if (bitmap == null) { + originalBitmap = BitmapFactory.decodeByteArray( + inputJBytes, + 0, + inputData.length, + ); + if (originalBitmap == null) { throw Exception('Failed to decode image. Invalid image data.'); } + switch (resizeMode) { + case OriginalResizeMode(): + bitmapToCompress = originalBitmap; + case ExactResizeMode(width: final w, height: final h): + scaledBitmap = Bitmap.createScaledBitmap( + originalBitmap, + w, + h, + true, // filter + ); + bitmapToCompress = scaledBitmap; + case FitResizeMode(:final width, :final height): + final originalWidth = originalBitmap.getWidth(); + final originalHeight = originalBitmap.getHeight(); + + if (originalWidth <= width && originalHeight <= height) { + bitmapToCompress = originalBitmap; + } else { + final aspectRatio = originalWidth / originalHeight; + var newWidth = width.toDouble(); + var newHeight = newWidth / aspectRatio; + + if (newHeight > height) { + newHeight = height.toDouble(); + newWidth = newHeight * aspectRatio; + } + + scaledBitmap = Bitmap.createScaledBitmap( + originalBitmap, + newWidth.round(), + newHeight.round(), + true, // filter + ); + bitmapToCompress = scaledBitmap; + } + } + + if (bitmapToCompress == null) { + // This should not happen if originalBitmap is valid + throw Exception('Bitmap could not be prepared for compression.'); + } + compressFormat = switch (format) { OutputFormat.jpeg => Bitmap$CompressFormat.JPEG, OutputFormat.png => Bitmap$CompressFormat.PNG, @@ -59,7 +110,11 @@ final class ImageConverterAndroid implements ImageConverterPlatform { }; outputStream = ByteArrayOutputStream(); - final success = bitmap.compress(compressFormat, quality, outputStream); + final success = bitmapToCompress.compress( + compressFormat, + quality, + outputStream, + ); if (!success) { throw Exception('Failed to compress bitmap.'); } @@ -72,8 +127,10 @@ final class ImageConverterAndroid implements ImageConverterPlatform { return Uint8List.fromList(outputJBytes.toList()); } finally { inputJBytes?.release(); - bitmap?.recycle(); - bitmap?.release(); + originalBitmap?.recycle(); + originalBitmap?.release(); + scaledBitmap?.recycle(); + scaledBitmap?.release(); compressFormat?.release(); outputStream?.release(); outputJBytes?.release(); diff --git a/lib/src/android/stub.dart b/lib/src/android/stub.dart index 454d5e4..907b412 100644 --- a/lib/src/android/stub.dart +++ b/lib/src/android/stub.dart @@ -1,7 +1,7 @@ import 'dart:typed_data'; +import 'package:platform_image_converter/platform_image_converter.dart'; import 'package:platform_image_converter/src/image_converter_platform_interface.dart'; -import 'package:platform_image_converter/src/output_format.dart'; final class ImageConverterAndroid implements ImageConverterPlatform { const ImageConverterAndroid(); @@ -11,5 +11,6 @@ final class ImageConverterAndroid implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async => throw UnimplementedError(); } diff --git a/lib/src/darwin/bindings.g.dart b/lib/src/darwin/bindings.g.dart index 5ca1664..e1be9d9 100644 --- a/lib/src/darwin/bindings.g.dart +++ b/lib/src/darwin/bindings.g.dart @@ -61,6 +61,62 @@ external int CFDataGetLength(CFDataRef theData); @ffi.Native Function(CFDataRef)>() external ffi.Pointer CFDataGetBytePtr(CFDataRef theData); +@ffi.Native() +external int CGImageGetWidth(CGImageRef image); + +@ffi.Native() +external int CGImageGetHeight(CGImageRef image); + +@ffi.Native() +external int CGImageGetBitsPerComponent(CGImageRef image); + +@ffi.Native() +external CGColorSpaceRef CGImageGetColorSpace(CGImageRef image); + +@ffi.Native() +external int CGImageGetBitmapInfo(CGImageRef image); + +@ffi.Native() +external void CGContextDrawImage( + CGContextRef c, + objc.CGRect rect, + CGImageRef image, +); + +@ffi.Native( + symbol: 'CGContextSetInterpolationQuality', +) +external void _CGContextSetInterpolationQuality(CGContextRef c, int quality); + +void CGContextSetInterpolationQuality( + CGContextRef c, + CGInterpolationQuality quality, +) => _CGContextSetInterpolationQuality(c, quality.value); + +@ffi.Native< + CGContextRef Function( + ffi.Pointer, + ffi.Size, + ffi.Size, + ffi.Size, + ffi.Size, + CGColorSpaceRef, + ffi.Uint32, + ) +>() +external CGContextRef CGBitmapContextCreate( + ffi.Pointer data, + int width, + int height, + int bitsPerComponent, + int bytesPerRow, + CGColorSpaceRef space, + int bitmapInfo, +); + +@ffi.Native() +external CGImageRef CGBitmapContextCreateImage(CGContextRef context); + @ffi.Native() external CGImageSourceRef CGImageSourceCreateWithData( CFDataRef data, @@ -208,10 +264,55 @@ final class CGImageSource extends ffi.Opaque {} typedef CGImageSourceRef = ffi.Pointer; +final class CGContext extends ffi.Opaque {} + +typedef CGContextRef = ffi.Pointer; + +final class CGColorSpace extends ffi.Opaque {} + +typedef CGColorSpaceRef = ffi.Pointer; + final class CGImage extends ffi.Opaque {} typedef CGImageRef = ffi.Pointer; +sealed class CGBitmapInfo { + static const kCGBitmapAlphaInfoMask = 31; + static const kCGBitmapComponentInfoMask = 3840; + static const kCGBitmapByteOrderInfoMask = 28672; + static const kCGBitmapPixelFormatInfoMask = 983040; + static const kCGBitmapFloatInfoMask = 3840; + static const kCGBitmapByteOrderMask = 28672; + static const kCGBitmapFloatComponents = 256; + static const kCGBitmapByteOrderDefault = 0; + static const kCGBitmapByteOrder16Little = 4096; + static const kCGBitmapByteOrder32Little = 8192; + static const kCGBitmapByteOrder16Big = 12288; + static const kCGBitmapByteOrder32Big = 16384; +} + +enum CGInterpolationQuality { + kCGInterpolationDefault(0), + kCGInterpolationNone(1), + kCGInterpolationLow(2), + kCGInterpolationMedium(4), + kCGInterpolationHigh(3); + + final int value; + const CGInterpolationQuality(this.value); + + static CGInterpolationQuality fromValue(int value) => switch (value) { + 0 => kCGInterpolationDefault, + 1 => kCGInterpolationNone, + 2 => kCGInterpolationLow, + 4 => kCGInterpolationMedium, + 3 => kCGInterpolationHigh, + _ => throw ArgumentError( + 'Unknown value for CGInterpolationQuality: $value', + ), + }; +} + final class CGImageDestination extends ffi.Opaque {} typedef CGImageDestinationRef = ffi.Pointer; diff --git a/lib/src/darwin/native.dart b/lib/src/darwin/native.dart index 1b73519..fac9482 100644 --- a/lib/src/darwin/native.dart +++ b/lib/src/darwin/native.dart @@ -6,6 +6,7 @@ import 'package:objective_c/objective_c.dart'; import 'package:platform_image_converter/src/darwin/bindings.g.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. /// @@ -20,6 +21,9 @@ import 'package:platform_image_converter/src/output_format.dart'; /// **API Stack:** /// - `CGImageSourceCreateWithData`: Decode input image /// - `CGImageSourceCreateImageAtIndex`: Extract CGImage +/// - `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 @@ -36,11 +40,13 @@ final class ImageConverterDarwin implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async { Pointer? inputPtr; CFDataRef? cfData; CGImageSourceRef? imageSource; - CGImageRef? cgImage; + CGImageRef? originalImage; + CGImageRef? imageToEncode; CFMutableDataRef? outputData; CGImageDestinationRef? destination; CFDictionaryRef? properties; @@ -62,11 +68,42 @@ final class ImageConverterDarwin implements ImageConverterPlatform { throw Exception('Failed to create CGImageSource. Invalid image data.'); } - cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nullptr); - if (cgImage == nullptr) { + originalImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nullptr); + if (originalImage == nullptr) { throw Exception('Failed to decode image.'); } + switch (resizeMode) { + case OriginalResizeMode(): + imageToEncode = originalImage; + case ExactResizeMode(width: final w, height: final h): + imageToEncode = _resizeImage(originalImage, w, h); + case FitResizeMode(:final width, :final height): + final originalWidth = CGImageGetWidth(originalImage); + final originalHeight = CGImageGetHeight(originalImage); + + if (originalWidth <= width && originalHeight <= height) { + imageToEncode = originalImage; + } else { + final aspectRatio = originalWidth / originalHeight; + var newWidth = width.toDouble(); + var newHeight = newWidth / aspectRatio; + + if (newHeight > height) { + newHeight = height.toDouble(); + newWidth = newHeight * aspectRatio; + } + imageToEncode = _resizeImage( + originalImage, + newWidth.round(), + newHeight.round(), + ); + } + } + if (imageToEncode == nullptr) { + throw Exception('Failed to prepare image for encoding.'); + } + outputData = CFDataCreateMutable(kCFAllocatorDefault, 0); if (outputData == nullptr) { throw Exception('Failed to create output CFData.'); @@ -101,7 +138,11 @@ final class ImageConverterDarwin implements ImageConverterPlatform { } properties = _createPropertiesForFormat(format, quality); - CGImageDestinationAddImage(destination, cgImage, properties ?? nullptr); + CGImageDestinationAddImage( + destination, + imageToEncode, + properties ?? nullptr, + ); final success = CGImageDestinationFinalize(destination); if (!success) { @@ -119,7 +160,10 @@ final class ImageConverterDarwin implements ImageConverterPlatform { if (inputPtr != null) calloc.free(inputPtr); if (cfData != null) CFRelease(cfData.cast()); if (imageSource != null) CFRelease(imageSource.cast()); - if (cgImage != null) CFRelease(cgImage.cast()); + if (imageToEncode != null && imageToEncode != originalImage) { + CFRelease(imageToEncode.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()); @@ -157,4 +201,53 @@ final class ImageConverterDarwin implements ImageConverterPlatform { ); }); } + + CGImageRef _resizeImage(CGImageRef originalImage, int width, int height) { + CGColorSpaceRef? colorSpace; + CGContextRef? context; + try { + colorSpace = CGImageGetColorSpace(originalImage); + if (colorSpace == nullptr) { + throw Exception('Failed to get color space from image.'); + } + + final bitsPerComponent = CGImageGetBitsPerComponent(originalImage); + final bitmapInfo = CGImageGetBitmapInfo(originalImage); + + context = CGBitmapContextCreate( + nullptr, + width, + height, + bitsPerComponent, + 0, // bytesPerRow (0 means calculate automatically) + colorSpace, + bitmapInfo, + ); + if (context == nullptr) { + throw Exception('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 Exception('Failed to create resized image from context.'); + } + return resizedImage; + } finally { + if (colorSpace != null) CFRelease(colorSpace.cast()); + if (context != null) CFRelease(context.cast()); + } + } } diff --git a/lib/src/darwin/stub.dart b/lib/src/darwin/stub.dart index c8e8082..bf3c39c 100644 --- a/lib/src/darwin/stub.dart +++ b/lib/src/darwin/stub.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; 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'; final class ImageConverterDarwin implements ImageConverterPlatform { const ImageConverterDarwin(); @@ -11,5 +12,6 @@ final class ImageConverterDarwin implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async => throw UnimplementedError(); } diff --git a/lib/src/image_converter_platform_interface.dart b/lib/src/image_converter_platform_interface.dart index e8716a9..70368f8 100644 --- a/lib/src/image_converter_platform_interface.dart +++ b/lib/src/image_converter_platform_interface.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'output_format.dart'; +import 'output_resize.dart'; /// Platform-specific image converter interface. /// @@ -19,6 +20,7 @@ abstract interface class ImageConverterPlatform { /// - [inputData]: Raw bytes of the image to convert /// - [format]: Target [OutputFormat] (default: [OutputFormat.jpeg]) /// - [quality]: Compression quality 1-100 for lossy formats (default: 95) + /// - [resizeMode]: The resize mode to apply to the image. /// /// **Returns:** Converted image data as [Uint8List] /// @@ -30,6 +32,7 @@ abstract interface class ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) { throw UnimplementedError('convert() has not been implemented.'); } diff --git a/lib/src/output_resize.dart b/lib/src/output_resize.dart new file mode 100644 index 0000000..0f0ae4c --- /dev/null +++ b/lib/src/output_resize.dart @@ -0,0 +1,36 @@ +/// A sealed class representing different ways to resize an image. +sealed class ResizeMode { + const ResizeMode(); +} + +/// A resize mode that keeps the original dimensions of the image. +class OriginalResizeMode extends ResizeMode { + const OriginalResizeMode(); +} + +/// A resize mode that resizes the image to exact dimensions, +/// possibly changing the aspect ratio. +class ExactResizeMode extends ResizeMode { + const ExactResizeMode({required this.width, required this.height}); + + /// The target width for the resized image. + final int width; + + /// The target height for the resized image. + final int height; +} + +/// A resize mode that fits the image within the specified dimensions while +/// maintaining the aspect ratio. +/// +/// If the image is smaller than the specified dimensions, it will not be +/// scaled up. +class FitResizeMode extends ResizeMode { + const FitResizeMode({required this.width, required this.height}); + + /// The maximum width for the resized image. + final int width; + + /// The maximum height for the resized image. + final int height; +} diff --git a/lib/src/web/stub.dart b/lib/src/web/stub.dart index 775b462..84a5f99 100644 --- a/lib/src/web/stub.dart +++ b/lib/src/web/stub.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; 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'; final class ImageConverterWeb implements ImageConverterPlatform { const ImageConverterWeb(); @@ -11,5 +12,6 @@ final class ImageConverterWeb implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async => throw UnimplementedError(); } diff --git a/lib/src/web/web.dart b/lib/src/web/web.dart index e50a983..c083f2f 100644 --- a/lib/src/web/web.dart +++ b/lib/src/web/web.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; 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'; import 'package:web/web.dart'; /// Web image converter using Canvas API. @@ -40,6 +41,7 @@ final class ImageConverterWeb implements ImageConverterPlatform { required Uint8List inputData, OutputFormat format = OutputFormat.jpeg, int quality = 100, + ResizeMode resizeMode = const OriginalResizeMode(), }) async { final img = HTMLImageElement(); final decodeCompeleter = Completer(); @@ -56,10 +58,40 @@ final class ImageConverterWeb implements ImageConverterPlatform { URL.revokeObjectURL(url); final canvas = HTMLCanvasElement(); - canvas.width = img.width; - canvas.height = img.height; + + final int destWidth; + final int destHeight; + + switch (resizeMode) { + case OriginalResizeMode(): + destWidth = img.width; + destHeight = img.height; + case ExactResizeMode(width: final w, height: final h): + destWidth = w; + destHeight = h; + case FitResizeMode(:final width, :final height): + if (img.width <= width && img.height <= height) { + destWidth = img.width; + destHeight = img.height; + } else { + final aspectRatio = img.width / img.height; + var newWidth = width.toDouble(); + var newHeight = newWidth / aspectRatio; + + if (newHeight > height) { + newHeight = height.toDouble(); + newWidth = newHeight * aspectRatio; + } + destWidth = newWidth.round(); + destHeight = newHeight.round(); + } + } + + canvas.width = destWidth; + canvas.height = destHeight; + final ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - ctx.drawImage(img, 0, 0); + ctx.drawImage(img, 0, 0, destWidth, destHeight); final encodeCompleter = Completer(); final type = switch (format) {