commit
f9cebdaa56
109
README.md
109
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 a width of 200, scaling height proportionally
|
||||
final resizedData = await ImageConverter.convert(
|
||||
inputData: imageData,
|
||||
format: OutputFormat.png,
|
||||
resizeMode: const FitResizeMode(width: 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<Uint8List> 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<Uint8List>` containing the converted image data
|
||||
**Returns:** `Future<Uint8List>` 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({int? width, int? height})`**: Fits the image within the specified dimensions while maintaining the aspect ratio. At least one of `width` or `height` must be provided. If only one dimension is provided, the other is scaled proportionally. 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).
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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,120 @@ 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));
|
||||
});
|
||||
|
||||
test('FitResizeMode with only width maintains aspect ratio', () async {
|
||||
// Original jpeg.jpg is 1502x2000
|
||||
final targetWidth = 150;
|
||||
final expectedWidth = 150;
|
||||
final expectedHeight = 200;
|
||||
|
||||
final converted = await ImageConverter.convert(
|
||||
inputData: jpegData,
|
||||
format: OutputFormat.jpeg,
|
||||
resizeMode: FitResizeMode(width: targetWidth),
|
||||
);
|
||||
|
||||
final resizedImage = img.decodeImage(converted)!;
|
||||
// Allow for small rounding differences
|
||||
expect(resizedImage.width, closeTo(expectedWidth, 1));
|
||||
expect(resizedImage.height, closeTo(expectedHeight, 1));
|
||||
});
|
||||
|
||||
test('FitResizeMode with only height maintains aspect ratio', () async {
|
||||
// Original jpeg.jpg is 1502x2000
|
||||
final targetHeight = 200;
|
||||
final expectedWidth = 150;
|
||||
final expectedHeight = 200;
|
||||
|
||||
final converted = await ImageConverter.convert(
|
||||
inputData: jpegData,
|
||||
format: OutputFormat.jpeg,
|
||||
resizeMode: FitResizeMode(height: targetHeight),
|
||||
);
|
||||
|
||||
final resizedImage = img.decodeImage(converted)!;
|
||||
// Allow for small rounding differences
|
||||
expect(resizedImage.width, closeTo(expectedWidth, 1));
|
||||
expect(resizedImage.height, closeTo(expectedHeight, 1));
|
||||
});
|
||||
});
|
||||
|
||||
group('File size consistency tests', () {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ class MainPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MainPageState extends State<MainPage> {
|
||||
final _widthController = TextEditingController();
|
||||
final _heightController = TextEditingController();
|
||||
|
||||
Uint8List? _originalImage;
|
||||
String? _originalName;
|
||||
Uint8List? _convertedImage;
|
||||
|
|
@ -37,6 +40,13 @@ class _MainPageState extends State<MainPage> {
|
|||
bool _isLoading = false;
|
||||
double _quality = 90;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_widthController.dispose();
|
||||
_heightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
||||
|
|
@ -55,10 +65,20 @@ class _MainPageState extends State<MainPage> {
|
|||
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),
|
||||
(null, final h?) => FitResizeMode(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<MainPage> {
|
|||
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<MainPage> {
|
|||
? 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<MainPage> {
|
|||
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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
ffigen.dart
14
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',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,40 +66,29 @@ class ImageConverter {
|
|||
required Uint8List inputData,
|
||||
OutputFormat format = OutputFormat.jpeg,
|
||||
int quality = 100,
|
||||
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||
bool runInIsolate = true,
|
||||
}) async {
|
||||
if (runInIsolate) {
|
||||
return await compute(
|
||||
_convertInIsolate,
|
||||
_ConvertRequest(inputData, format, quality, defaultTargetPlatform),
|
||||
);
|
||||
return await compute(_convertInIsolate, (
|
||||
inputData: inputData,
|
||||
format: format,
|
||||
quality: quality,
|
||||
resizeMode: resizeMode,
|
||||
platform: defaultTargetPlatform,
|
||||
));
|
||||
} else {
|
||||
// The original implementation for those who opt-out.
|
||||
return _platform.convert(
|
||||
inputData: inputData,
|
||||
format: format,
|
||||
quality: quality,
|
||||
resizeMode: resizeMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to pass arguments to the isolate.
|
||||
@immutable
|
||||
class _ConvertRequest {
|
||||
final Uint8List inputData;
|
||||
final OutputFormat format;
|
||||
final int quality;
|
||||
final TargetPlatform platform;
|
||||
|
||||
const _ConvertRequest(
|
||||
this.inputData,
|
||||
this.format,
|
||||
this.quality,
|
||||
this.platform,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the platform-specific converter instance.
|
||||
ImageConverterPlatform _getPlatformForTarget(TargetPlatform platform) {
|
||||
if (kIsWeb) {
|
||||
|
|
@ -112,11 +104,21 @@ ImageConverterPlatform _getPlatformForTarget(TargetPlatform platform) {
|
|||
}
|
||||
|
||||
/// Top-level function for `compute`.
|
||||
Future<Uint8List> _convertInIsolate(_ConvertRequest request) {
|
||||
Future<Uint8List> _convertInIsolate(
|
||||
({
|
||||
Uint8List inputData,
|
||||
OutputFormat format,
|
||||
int quality,
|
||||
ResizeMode resizeMode,
|
||||
TargetPlatform platform,
|
||||
})
|
||||
request,
|
||||
) {
|
||||
final platform = _getPlatformForTarget(request.platform);
|
||||
return platform.convert(
|
||||
inputData: request.inputData,
|
||||
format: request.format,
|
||||
quality: request.quality,
|
||||
resizeMode: request.resizeMode,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,93 @@ 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();
|
||||
|
||||
double newWidth;
|
||||
double newHeight;
|
||||
|
||||
if (width != null && height != null) {
|
||||
if (originalWidth <= width && originalHeight <= height) {
|
||||
bitmapToCompress = originalBitmap;
|
||||
break;
|
||||
}
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = width.toDouble();
|
||||
newHeight = newWidth / aspectRatio;
|
||||
if (newHeight > height) {
|
||||
newHeight = height.toDouble();
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
} else if (width != null) {
|
||||
if (originalWidth <= width) {
|
||||
bitmapToCompress = originalBitmap;
|
||||
break;
|
||||
}
|
||||
newWidth = width.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (height != null) {
|
||||
if (originalHeight <= height) {
|
||||
bitmapToCompress = originalBitmap;
|
||||
break;
|
||||
}
|
||||
newHeight = height.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = newHeight * aspectRatio;
|
||||
} else {
|
||||
// This case should not be reachable due to the assertion
|
||||
// in the FitResizeMode constructor.
|
||||
bitmapToCompress = originalBitmap;
|
||||
break;
|
||||
}
|
||||
|
||||
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 +135,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 +152,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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,62 @@ external int CFDataGetLength(CFDataRef theData);
|
|||
@ffi.Native<ffi.Pointer<ffi.UnsignedChar> Function(CFDataRef)>()
|
||||
external ffi.Pointer<ffi.UnsignedChar> CFDataGetBytePtr(CFDataRef theData);
|
||||
|
||||
@ffi.Native<ffi.Size Function(CGImageRef)>()
|
||||
external int CGImageGetWidth(CGImageRef image);
|
||||
|
||||
@ffi.Native<ffi.Size Function(CGImageRef)>()
|
||||
external int CGImageGetHeight(CGImageRef image);
|
||||
|
||||
@ffi.Native<ffi.Size Function(CGImageRef)>()
|
||||
external int CGImageGetBitsPerComponent(CGImageRef image);
|
||||
|
||||
@ffi.Native<CGColorSpaceRef Function(CGImageRef)>()
|
||||
external CGColorSpaceRef CGImageGetColorSpace(CGImageRef image);
|
||||
|
||||
@ffi.Native<ffi.Uint32 Function(CGImageRef)>()
|
||||
external int CGImageGetBitmapInfo(CGImageRef image);
|
||||
|
||||
@ffi.Native<ffi.Void Function(CGContextRef, objc.CGRect, CGImageRef)>()
|
||||
external void CGContextDrawImage(
|
||||
CGContextRef c,
|
||||
objc.CGRect rect,
|
||||
CGImageRef image,
|
||||
);
|
||||
|
||||
@ffi.Native<ffi.Void Function(CGContextRef, ffi.Int32)>(
|
||||
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.Void>,
|
||||
ffi.Size,
|
||||
ffi.Size,
|
||||
ffi.Size,
|
||||
ffi.Size,
|
||||
CGColorSpaceRef,
|
||||
ffi.Uint32,
|
||||
)
|
||||
>()
|
||||
external CGContextRef CGBitmapContextCreate(
|
||||
ffi.Pointer<ffi.Void> data,
|
||||
int width,
|
||||
int height,
|
||||
int bitsPerComponent,
|
||||
int bytesPerRow,
|
||||
CGColorSpaceRef space,
|
||||
int bitmapInfo,
|
||||
);
|
||||
|
||||
@ffi.Native<CGImageRef Function(CGContextRef)>()
|
||||
external CGImageRef CGBitmapContextCreateImage(CGContextRef context);
|
||||
|
||||
@ffi.Native<CGImageSourceRef Function(CFDataRef, CFDictionaryRef)>()
|
||||
external CGImageSourceRef CGImageSourceCreateWithData(
|
||||
CFDataRef data,
|
||||
|
|
@ -208,10 +264,55 @@ final class CGImageSource extends ffi.Opaque {}
|
|||
|
||||
typedef CGImageSourceRef = ffi.Pointer<CGImageSource>;
|
||||
|
||||
final class CGContext extends ffi.Opaque {}
|
||||
|
||||
typedef CGContextRef = ffi.Pointer<CGContext>;
|
||||
|
||||
final class CGColorSpace extends ffi.Opaque {}
|
||||
|
||||
typedef CGColorSpaceRef = ffi.Pointer<CGColorSpace>;
|
||||
|
||||
final class CGImage extends ffi.Opaque {}
|
||||
|
||||
typedef CGImageRef = ffi.Pointer<CGImage>;
|
||||
|
||||
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<CGImageDestination>;
|
||||
|
|
|
|||
|
|
@ -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<Uint8>? inputPtr;
|
||||
CFDataRef? cfData;
|
||||
CGImageSourceRef? imageSource;
|
||||
CGImageRef? cgImage;
|
||||
CGImageRef? originalImage;
|
||||
CGImageRef? imageToEncode;
|
||||
CFMutableDataRef? outputData;
|
||||
CGImageDestinationRef? destination;
|
||||
CFDictionaryRef? properties;
|
||||
|
|
@ -62,11 +68,67 @@ 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);
|
||||
|
||||
double newWidth;
|
||||
double newHeight;
|
||||
|
||||
if (width != null && height != null) {
|
||||
if (originalWidth <= width && originalHeight <= height) {
|
||||
imageToEncode = originalImage;
|
||||
break;
|
||||
}
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = width.toDouble();
|
||||
newHeight = newWidth / aspectRatio;
|
||||
if (newHeight > height) {
|
||||
newHeight = height.toDouble();
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
} else if (width != null) {
|
||||
if (originalWidth <= width) {
|
||||
imageToEncode = originalImage;
|
||||
break;
|
||||
}
|
||||
newWidth = width.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (height != null) {
|
||||
if (originalHeight <= height) {
|
||||
imageToEncode = originalImage;
|
||||
break;
|
||||
}
|
||||
newHeight = height.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = newHeight * aspectRatio;
|
||||
} else {
|
||||
// This case should not be reachable due to the assertion
|
||||
// in the FitResizeMode constructor.
|
||||
imageToEncode = originalImage;
|
||||
break;
|
||||
}
|
||||
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 +163,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 +185,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 +226,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<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 Exception('Failed to create resized image from context.');
|
||||
}
|
||||
return resizedImage;
|
||||
} finally {
|
||||
if (colorSpace != null) CFRelease(colorSpace.cast());
|
||||
if (context != null) CFRelease(context.cast());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/// 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({this.width, this.height})
|
||||
: assert(width != null || height != null);
|
||||
|
||||
/// The maximum width for the resized image.
|
||||
final int? width;
|
||||
|
||||
/// The maximum height for the resized image.
|
||||
final int? height;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>();
|
||||
|
|
@ -56,10 +58,72 @@ 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):
|
||||
final originalWidth = img.width;
|
||||
final originalHeight = img.height;
|
||||
|
||||
double newWidth;
|
||||
double newHeight;
|
||||
|
||||
if (width != null && height != null) {
|
||||
if (originalWidth <= width && originalHeight <= height) {
|
||||
destWidth = originalWidth;
|
||||
destHeight = originalHeight;
|
||||
break;
|
||||
}
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = width.toDouble();
|
||||
newHeight = newWidth / aspectRatio;
|
||||
if (newHeight > height) {
|
||||
newHeight = height.toDouble();
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
} else if (width != null) {
|
||||
if (originalWidth <= width) {
|
||||
destWidth = originalWidth;
|
||||
destHeight = originalHeight;
|
||||
break;
|
||||
}
|
||||
newWidth = width.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (height != null) {
|
||||
if (originalHeight <= height) {
|
||||
destWidth = originalWidth;
|
||||
destHeight = originalHeight;
|
||||
break;
|
||||
}
|
||||
newHeight = height.toDouble();
|
||||
final aspectRatio = originalWidth / originalHeight;
|
||||
newWidth = newHeight * aspectRatio;
|
||||
} else {
|
||||
// This case should not be reachable due to the assertion
|
||||
// in the FitResizeMode constructor.
|
||||
destWidth = originalWidth;
|
||||
destHeight = originalHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
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<Blob>();
|
||||
final type = switch (format) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue