commit
f9cebdaa56
109
README.md
109
README.md
|
|
@ -1,19 +1,20 @@
|
||||||
# platform_image_converter
|
# 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
|
## 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.
|
- 🖼️ **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.
|
- 🔒 **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 Support
|
||||||
|
|
||||||
| Platform | Minimum Version | API Used |
|
| Platform | Minimum Version | API Used |
|
||||||
|----------|-----------------|----------|
|
|----------|-----------------|----------|
|
||||||
| iOS | 14.0 | ImageIO (CoreFoundation, CoreGraphics) |
|
| iOS | 14.0 | ImageIO, Core Graphics |
|
||||||
| macOS | 10.15 | ImageIO (CoreFoundation, CoreGraphics) |
|
| macOS | 10.15 | ImageIO, Core Graphics |
|
||||||
| Android | 7 | BitmapFactory, Bitmap compression |
|
| Android | 7 | BitmapFactory, Bitmap compression |
|
||||||
| Web | - | Canvas API |
|
| Web | - | Canvas API |
|
||||||
|
|
||||||
|
|
@ -46,6 +47,13 @@ final jpegData = await ImageConverter.convert(
|
||||||
quality: 90,
|
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
|
// Convert any format to PNG
|
||||||
final pngData = await ImageConverter.convert(
|
final pngData = await ImageConverter.convert(
|
||||||
inputData: imageData,
|
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:
|
The supported output formats are defined by the `OutputFormat` enum, with platform-specific limitations:
|
||||||
- **JPEG**: Supported on all platforms.
|
- **JPEG**: Supported on all platforms.
|
||||||
- **PNG**: 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.
|
- **HEIC**: Supported on iOS/macOS only.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
@ -76,87 +84,74 @@ static Future<Uint8List> convert({
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async
|
}) async
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
- `inputData` (`Uint8List`): Raw image data to convert
|
- `inputData` (`Uint8List`): Raw image data to convert.
|
||||||
- `format` (`OutputFormat`): Target image format (default: JPEG)
|
- `format` (`OutputFormat`): Target image format (default: JPEG).
|
||||||
- `quality` (`int`): Compression quality 1-100 (default: 100, only for lossy formats)
|
- `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:**
|
**Throws:**
|
||||||
- `UnsupportedError`: If the platform or format is not supported
|
- `UnsupportedError`: If the platform or format is not supported.
|
||||||
- `Exception`: If conversion fails
|
- `Exception`: If conversion fails.
|
||||||
|
|
||||||
### `OutputFormat` Enum
|
### `OutputFormat` Enum
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
enum OutputFormat {
|
enum OutputFormat {
|
||||||
/// JPEG format (.jpg, .jpeg)
|
jpeg, // .jpg, .jpeg
|
||||||
/// Lossy compression, suitable for photos
|
png, // .png
|
||||||
jpeg,
|
webp, // .webp
|
||||||
|
heic, // .heic
|
||||||
/// 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,
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `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
|
## Implementation Details
|
||||||
|
|
||||||
### iOS/macOS Implementation
|
### 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
|
1. **Decoding**: `CGImageSourceCreateWithData` reads input data.
|
||||||
2. **Rendering**: `CGImageSourceCreateImageAtIndex` decodes to `CGImage`
|
2. **Resizing**:
|
||||||
3. **Encoding**: `CGImageDestinationCreateWithData` encodes to target format
|
- `CGBitmapContextCreate` creates a new bitmap context with the target dimensions.
|
||||||
4. **Quality**: Uses `kCGImageDestinationLossyCompressionQuality` for JPEG/WebP
|
- `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.
|
||||||
**Key Functions:**
|
3. **Encoding**: `CGImageDestinationCreateWithData` encodes the final `CGImage` to the target format.
|
||||||
- `CFDataCreate`: Create immutable data from input bytes
|
4. **Quality**: Uses `kCGImageDestinationLossyCompressionQuality` for JPEG/HEIC.
|
||||||
- `CGImageSourceCreateWithData`: Create image source from data
|
|
||||||
- `CGImageDestinationCreateWithData`: Create image destination
|
|
||||||
- `CGImageDestinationAddImage`: Add image to destination
|
|
||||||
- `CGImageDestinationFinalize`: Complete encoding
|
|
||||||
|
|
||||||
### Android Implementation
|
### 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)):
|
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
|
1. **Decoding**: `BitmapFactory.decodeByteArray` handles all supported formats.
|
||||||
2. **Compression**: `Bitmap.compress` encodes to target format
|
2. **Resizing**: `Bitmap.createScaledBitmap` is used to create a new, resized bitmap from the original, with filtering enabled for smoother results.
|
||||||
3. **Buffer Management**: `ByteArrayOutputStream` manages output data
|
3. **Compression**: `Bitmap.compress` encodes the final bitmap to the target format.
|
||||||
|
4. **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
|
|
||||||
|
|
||||||
### Web Implementation
|
### Web Implementation
|
||||||
|
|
||||||
The Web implementation uses the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) for image conversion:
|
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
|
1. **Decoding**: `HTMLImageElement` loads image data via a Blob URL.
|
||||||
2. **Rendering**: `CanvasRenderingContext2D.drawImage` renders the image to canvas
|
2. **Resizing & Rendering**: `CanvasRenderingContext2D.drawImage` renders the image to a canvas with the target dimensions, effectively resizing it.
|
||||||
3. **Encoding**: `HTMLCanvasElement.toBlob` encodes to target format
|
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)
|
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
|
|
||||||
|
|
||||||
**Key Limitations:**
|
**Key Limitations:**
|
||||||
- HEIC format is not supported on Web platform
|
- HEIC format is not supported on Web platform.
|
||||||
- Output format depends on browser support (JPEG and PNG are universally supported, WebP is widely supported)
|
- 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:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:platform_image_converter/platform_image_converter.dart';
|
import 'package:platform_image_converter/platform_image_converter.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -11,14 +12,120 @@ void main() {
|
||||||
late Uint8List jpegData;
|
late Uint8List jpegData;
|
||||||
late Uint8List pngData;
|
late Uint8List pngData;
|
||||||
late Uint8List webpData;
|
late Uint8List webpData;
|
||||||
late Uint8List heicData;
|
|
||||||
|
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
// Load test images from assets
|
// Load test images from assets
|
||||||
jpegData = await _loadAssetImage('assets/jpeg.jpg');
|
jpegData = await _loadAssetImage('assets/jpeg.jpg');
|
||||||
pngData = await _loadAssetImage('assets/png.png');
|
pngData = await _loadAssetImage('assets/png.png');
|
||||||
webpData = await _loadAssetImage('assets/webp.webp');
|
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', () {
|
group('File size consistency tests', () {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ class MainPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainPageState extends State<MainPage> {
|
class _MainPageState extends State<MainPage> {
|
||||||
|
final _widthController = TextEditingController();
|
||||||
|
final _heightController = TextEditingController();
|
||||||
|
|
||||||
Uint8List? _originalImage;
|
Uint8List? _originalImage;
|
||||||
String? _originalName;
|
String? _originalName;
|
||||||
Uint8List? _convertedImage;
|
Uint8List? _convertedImage;
|
||||||
|
|
@ -37,6 +40,13 @@ class _MainPageState extends State<MainPage> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
double _quality = 90;
|
double _quality = 90;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_widthController.dispose();
|
||||||
|
_heightController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
Future<void> _pickImage() async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
final pickedFile = await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
|
@ -55,10 +65,20 @@ class _MainPageState extends State<MainPage> {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final sw = Stopwatch()..start();
|
final sw = Stopwatch()..start();
|
||||||
try {
|
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(
|
final converted = await ImageConverter.convert(
|
||||||
inputData: _originalImage!,
|
inputData: _originalImage!,
|
||||||
format: format,
|
format: format,
|
||||||
quality: _quality.round(),
|
quality: _quality.round(),
|
||||||
|
resizeMode: resizeMode,
|
||||||
);
|
);
|
||||||
sw.stop();
|
sw.stop();
|
||||||
_convertedImage = converted;
|
_convertedImage = converted;
|
||||||
|
|
@ -81,8 +101,9 @@ class _MainPageState extends State<MainPage> {
|
||||||
appBar: AppBar(title: const Text('platform_image_converter Demo')),
|
appBar: AppBar(title: const Text('platform_image_converter Demo')),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const .all(16),
|
padding: const .all(16),
|
||||||
|
keyboardDismissBehavior: .onDrag,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: .stretch,
|
||||||
children: [
|
children: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _pickImage,
|
onPressed: _pickImage,
|
||||||
|
|
@ -100,6 +121,31 @@ class _MainPageState extends State<MainPage> {
|
||||||
? null
|
? null
|
||||||
: (v) => setState(() => _quality = v),
|
: (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) ...[
|
if (_originalImage != null) ...[
|
||||||
Text('Original Image ($_originalName): '),
|
Text('Original Image ($_originalName): '),
|
||||||
Image.memory(_originalImage!, height: 180),
|
Image.memory(_originalImage!, height: 180),
|
||||||
|
|
@ -143,7 +189,7 @@ class _MainPageState extends State<MainPage> {
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Text('Converted ($_convertedFormat):'),
|
Text('Converted ($_convertedFormat):'),
|
||||||
Image.memory(_convertedImage!, height: 180),
|
Image.memory(_convertedImage!, height: 180, fit: .contain),
|
||||||
Text('Size: ${_convertedImage!.lengthInBytes} bytes'),
|
Text('Size: ${_convertedImage!.lengthInBytes} bytes'),
|
||||||
if (_convertElapsedMs != null)
|
if (_convertElapsedMs != null)
|
||||||
Text('Convert time: $_convertElapsedMs ms'),
|
Text('Convert time: $_convertElapsedMs ms'),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.7"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -202,6 +210,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -383,6 +399,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -406,6 +430,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.3"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -523,6 +555,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0"
|
version: "3.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ dependencies:
|
||||||
|
|
||||||
platform_image_converter:
|
platform_image_converter:
|
||||||
path: ../
|
path: ../
|
||||||
image_picker:
|
image_picker: ^1.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -21,6 +21,7 @@ dev_dependencies:
|
||||||
integration_test:
|
integration_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
image: ^4.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
|
||||||
14
ffigen.dart
14
ffigen.dart
|
|
@ -26,6 +26,18 @@ final config = FfiGenerator(
|
||||||
'CGImageDestinationCreateWithData',
|
'CGImageDestinationCreateWithData',
|
||||||
'CGImageDestinationAddImage',
|
'CGImageDestinationAddImage',
|
||||||
'CGImageDestinationFinalize',
|
'CGImageDestinationFinalize',
|
||||||
|
// CGImage operations
|
||||||
|
'CGImageGetBitsPerComponent',
|
||||||
|
'CGImageGetBitmapInfo',
|
||||||
|
'CGImageGetColorSpace',
|
||||||
|
'CGImageGetWidth',
|
||||||
|
'CGImageGetHeight',
|
||||||
|
// CGContext operations
|
||||||
|
'CGContextDrawImage',
|
||||||
|
'CGContextSetInterpolationQuality',
|
||||||
|
// CGBitmapContext operations
|
||||||
|
'CGBitmapContextCreateImage',
|
||||||
|
'CGBitmapContextCreate',
|
||||||
// Memory management
|
// Memory management
|
||||||
'CFRelease',
|
'CFRelease',
|
||||||
}),
|
}),
|
||||||
|
|
@ -38,8 +50,10 @@ final config = FfiGenerator(
|
||||||
typedefs: Typedefs.includeSet({
|
typedefs: Typedefs.includeSet({
|
||||||
'CFDataRef',
|
'CFDataRef',
|
||||||
'CFDictionaryRef',
|
'CFDictionaryRef',
|
||||||
|
'CGContextRef',
|
||||||
'CGImageRef',
|
'CGImageRef',
|
||||||
'CGImageSourceRef',
|
'CGImageSourceRef',
|
||||||
|
'CGColorSpaceRef',
|
||||||
'CFMutableDataRef',
|
'CFMutableDataRef',
|
||||||
'CGImageDestinationRef',
|
'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/darwin/shared.dart';
|
||||||
import 'package:platform_image_converter/src/image_converter_platform_interface.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_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
import 'package:platform_image_converter/src/web/shared.dart';
|
import 'package:platform_image_converter/src/web/shared.dart';
|
||||||
|
|
||||||
export 'src/output_format.dart';
|
export 'src/output_format.dart';
|
||||||
|
export 'src/output_resize.dart';
|
||||||
|
|
||||||
/// Main entry point for image format conversion.
|
/// Main entry point for image format conversion.
|
||||||
///
|
///
|
||||||
|
|
@ -32,6 +34,7 @@ class ImageConverter {
|
||||||
/// - [inputData]: Raw bytes of the image to convert.
|
/// - [inputData]: Raw bytes of the image to convert.
|
||||||
/// - [format]: Target [OutputFormat]. Defaults to [OutputFormat.jpeg].
|
/// - [format]: Target [OutputFormat]. Defaults to [OutputFormat.jpeg].
|
||||||
/// - [quality]: Compression quality for lossy formats (1-100).
|
/// - [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.
|
/// - [runInIsolate]: Whether to run the conversion in a separate isolate.
|
||||||
/// Defaults to `true`.
|
/// Defaults to `true`.
|
||||||
///
|
///
|
||||||
|
|
@ -63,40 +66,29 @@ class ImageConverter {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
bool runInIsolate = true,
|
bool runInIsolate = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (runInIsolate) {
|
if (runInIsolate) {
|
||||||
return await compute(
|
return await compute(_convertInIsolate, (
|
||||||
_convertInIsolate,
|
inputData: inputData,
|
||||||
_ConvertRequest(inputData, format, quality, defaultTargetPlatform),
|
format: format,
|
||||||
);
|
quality: quality,
|
||||||
|
resizeMode: resizeMode,
|
||||||
|
platform: defaultTargetPlatform,
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
// The original implementation for those who opt-out.
|
// The original implementation for those who opt-out.
|
||||||
return _platform.convert(
|
return _platform.convert(
|
||||||
inputData: inputData,
|
inputData: inputData,
|
||||||
format: format,
|
format: format,
|
||||||
quality: quality,
|
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.
|
/// Returns the platform-specific converter instance.
|
||||||
ImageConverterPlatform _getPlatformForTarget(TargetPlatform platform) {
|
ImageConverterPlatform _getPlatformForTarget(TargetPlatform platform) {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
|
|
@ -112,11 +104,21 @@ ImageConverterPlatform _getPlatformForTarget(TargetPlatform platform) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level function for `compute`.
|
/// 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);
|
final platform = _getPlatformForTarget(request.platform);
|
||||||
return platform.convert(
|
return platform.convert(
|
||||||
inputData: request.inputData,
|
inputData: request.inputData,
|
||||||
format: request.format,
|
format: request.format,
|
||||||
quality: request.quality,
|
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/android/bindings.g.dart';
|
||||||
import 'package:platform_image_converter/src/image_converter_platform_interface.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_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
|
|
||||||
/// Android image converter using BitmapFactory and Bitmap compression.
|
/// Android image converter using BitmapFactory and Bitmap compression.
|
||||||
///
|
///
|
||||||
|
|
@ -17,6 +18,7 @@ import 'package:platform_image_converter/src/output_format.dart';
|
||||||
///
|
///
|
||||||
/// **API Stack:**
|
/// **API Stack:**
|
||||||
/// - `BitmapFactory.decodeByteArray`: Auto-detect and decode input
|
/// - `BitmapFactory.decodeByteArray`: Auto-detect and decode input
|
||||||
|
/// - `Bitmap.createScaledBitmap`: Resize image with filtering
|
||||||
/// - `Bitmap.compress`: Encode to target format with quality control
|
/// - `Bitmap.compress`: Encode to target format with quality control
|
||||||
/// - `ByteArrayOutputStream`: Memory-based output buffer
|
/// - `ByteArrayOutputStream`: Memory-based output buffer
|
||||||
///
|
///
|
||||||
|
|
@ -35,19 +37,93 @@ final class ImageConverterAndroid implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async {
|
}) async {
|
||||||
JByteArray? inputJBytes;
|
JByteArray? inputJBytes;
|
||||||
Bitmap? bitmap;
|
Bitmap? originalBitmap;
|
||||||
|
Bitmap? scaledBitmap;
|
||||||
|
Bitmap? bitmapToCompress;
|
||||||
Bitmap$CompressFormat? compressFormat;
|
Bitmap$CompressFormat? compressFormat;
|
||||||
ByteArrayOutputStream? outputStream;
|
ByteArrayOutputStream? outputStream;
|
||||||
JByteArray? outputJBytes;
|
JByteArray? outputJBytes;
|
||||||
try {
|
try {
|
||||||
inputJBytes = JByteArray.from(inputData);
|
inputJBytes = JByteArray.from(inputData);
|
||||||
bitmap = BitmapFactory.decodeByteArray(inputJBytes, 0, inputData.length);
|
originalBitmap = BitmapFactory.decodeByteArray(
|
||||||
if (bitmap == null) {
|
inputJBytes,
|
||||||
|
0,
|
||||||
|
inputData.length,
|
||||||
|
);
|
||||||
|
if (originalBitmap == null) {
|
||||||
throw Exception('Failed to decode image. Invalid image data.');
|
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) {
|
compressFormat = switch (format) {
|
||||||
OutputFormat.jpeg => Bitmap$CompressFormat.JPEG,
|
OutputFormat.jpeg => Bitmap$CompressFormat.JPEG,
|
||||||
OutputFormat.png => Bitmap$CompressFormat.PNG,
|
OutputFormat.png => Bitmap$CompressFormat.PNG,
|
||||||
|
|
@ -59,7 +135,11 @@ final class ImageConverterAndroid implements ImageConverterPlatform {
|
||||||
};
|
};
|
||||||
|
|
||||||
outputStream = ByteArrayOutputStream();
|
outputStream = ByteArrayOutputStream();
|
||||||
final success = bitmap.compress(compressFormat, quality, outputStream);
|
final success = bitmapToCompress.compress(
|
||||||
|
compressFormat,
|
||||||
|
quality,
|
||||||
|
outputStream,
|
||||||
|
);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
throw Exception('Failed to compress bitmap.');
|
throw Exception('Failed to compress bitmap.');
|
||||||
}
|
}
|
||||||
|
|
@ -72,8 +152,10 @@ final class ImageConverterAndroid implements ImageConverterPlatform {
|
||||||
return Uint8List.fromList(outputJBytes.toList());
|
return Uint8List.fromList(outputJBytes.toList());
|
||||||
} finally {
|
} finally {
|
||||||
inputJBytes?.release();
|
inputJBytes?.release();
|
||||||
bitmap?.recycle();
|
originalBitmap?.recycle();
|
||||||
bitmap?.release();
|
originalBitmap?.release();
|
||||||
|
scaledBitmap?.recycle();
|
||||||
|
scaledBitmap?.release();
|
||||||
compressFormat?.release();
|
compressFormat?.release();
|
||||||
outputStream?.release();
|
outputStream?.release();
|
||||||
outputJBytes?.release();
|
outputJBytes?.release();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:typed_data';
|
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/image_converter_platform_interface.dart';
|
||||||
import 'package:platform_image_converter/src/output_format.dart';
|
|
||||||
|
|
||||||
final class ImageConverterAndroid implements ImageConverterPlatform {
|
final class ImageConverterAndroid implements ImageConverterPlatform {
|
||||||
const ImageConverterAndroid();
|
const ImageConverterAndroid();
|
||||||
|
|
@ -11,5 +11,6 @@ final class ImageConverterAndroid implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async => throw UnimplementedError();
|
}) async => throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,62 @@ external int CFDataGetLength(CFDataRef theData);
|
||||||
@ffi.Native<ffi.Pointer<ffi.UnsignedChar> Function(CFDataRef)>()
|
@ffi.Native<ffi.Pointer<ffi.UnsignedChar> Function(CFDataRef)>()
|
||||||
external ffi.Pointer<ffi.UnsignedChar> CFDataGetBytePtr(CFDataRef theData);
|
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)>()
|
@ffi.Native<CGImageSourceRef Function(CFDataRef, CFDictionaryRef)>()
|
||||||
external CGImageSourceRef CGImageSourceCreateWithData(
|
external CGImageSourceRef CGImageSourceCreateWithData(
|
||||||
CFDataRef data,
|
CFDataRef data,
|
||||||
|
|
@ -208,10 +264,55 @@ final class CGImageSource extends ffi.Opaque {}
|
||||||
|
|
||||||
typedef CGImageSourceRef = ffi.Pointer<CGImageSource>;
|
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 {}
|
final class CGImage extends ffi.Opaque {}
|
||||||
|
|
||||||
typedef CGImageRef = ffi.Pointer<CGImage>;
|
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 {}
|
final class CGImageDestination extends ffi.Opaque {}
|
||||||
|
|
||||||
typedef CGImageDestinationRef = ffi.Pointer<CGImageDestination>;
|
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/darwin/bindings.g.dart';
|
||||||
import 'package:platform_image_converter/src/image_converter_platform_interface.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_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
|
|
||||||
/// iOS/macOS image converter using ImageIO framework.
|
/// iOS/macOS image converter using ImageIO framework.
|
||||||
///
|
///
|
||||||
|
|
@ -20,6 +21,9 @@ import 'package:platform_image_converter/src/output_format.dart';
|
||||||
/// **API Stack:**
|
/// **API Stack:**
|
||||||
/// - `CGImageSourceCreateWithData`: Decode input image
|
/// - `CGImageSourceCreateWithData`: Decode input image
|
||||||
/// - `CGImageSourceCreateImageAtIndex`: Extract CGImage
|
/// - `CGImageSourceCreateImageAtIndex`: Extract CGImage
|
||||||
|
/// - `CGBitmapContextCreate`: Create a canvas for resizing
|
||||||
|
/// - `CGContextDrawImage`: Draw and scale the image
|
||||||
|
/// - `CGBitmapContextCreateImage`: Extract resized CGImage
|
||||||
/// - `CGImageDestinationCreateWithData`: Create output stream
|
/// - `CGImageDestinationCreateWithData`: Create output stream
|
||||||
/// - `CGImageDestinationAddImage`: Add image with encoding options
|
/// - `CGImageDestinationAddImage`: Add image with encoding options
|
||||||
/// - `CGImageDestinationFinalize`: Complete encoding
|
/// - `CGImageDestinationFinalize`: Complete encoding
|
||||||
|
|
@ -36,11 +40,13 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async {
|
}) async {
|
||||||
Pointer<Uint8>? inputPtr;
|
Pointer<Uint8>? inputPtr;
|
||||||
CFDataRef? cfData;
|
CFDataRef? cfData;
|
||||||
CGImageSourceRef? imageSource;
|
CGImageSourceRef? imageSource;
|
||||||
CGImageRef? cgImage;
|
CGImageRef? originalImage;
|
||||||
|
CGImageRef? imageToEncode;
|
||||||
CFMutableDataRef? outputData;
|
CFMutableDataRef? outputData;
|
||||||
CGImageDestinationRef? destination;
|
CGImageDestinationRef? destination;
|
||||||
CFDictionaryRef? properties;
|
CFDictionaryRef? properties;
|
||||||
|
|
@ -62,11 +68,67 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
throw Exception('Failed to create CGImageSource. Invalid image data.');
|
throw Exception('Failed to create CGImageSource. Invalid image data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nullptr);
|
originalImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nullptr);
|
||||||
if (cgImage == nullptr) {
|
if (originalImage == nullptr) {
|
||||||
throw Exception('Failed to decode image.');
|
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);
|
outputData = CFDataCreateMutable(kCFAllocatorDefault, 0);
|
||||||
if (outputData == nullptr) {
|
if (outputData == nullptr) {
|
||||||
throw Exception('Failed to create output CFData.');
|
throw Exception('Failed to create output CFData.');
|
||||||
|
|
@ -101,7 +163,11 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
}
|
}
|
||||||
|
|
||||||
properties = _createPropertiesForFormat(format, quality);
|
properties = _createPropertiesForFormat(format, quality);
|
||||||
CGImageDestinationAddImage(destination, cgImage, properties ?? nullptr);
|
CGImageDestinationAddImage(
|
||||||
|
destination,
|
||||||
|
imageToEncode,
|
||||||
|
properties ?? nullptr,
|
||||||
|
);
|
||||||
|
|
||||||
final success = CGImageDestinationFinalize(destination);
|
final success = CGImageDestinationFinalize(destination);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
|
@ -119,7 +185,10 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
if (inputPtr != null) calloc.free(inputPtr);
|
if (inputPtr != null) calloc.free(inputPtr);
|
||||||
if (cfData != null) CFRelease(cfData.cast());
|
if (cfData != null) CFRelease(cfData.cast());
|
||||||
if (imageSource != null) CFRelease(imageSource.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 (outputData != null) CFRelease(outputData.cast());
|
||||||
if (destination != null) CFRelease(destination.cast());
|
if (destination != null) CFRelease(destination.cast());
|
||||||
if (properties != null) CFRelease(properties.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/image_converter_platform_interface.dart';
|
||||||
import 'package:platform_image_converter/src/output_format.dart';
|
import 'package:platform_image_converter/src/output_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
|
|
||||||
final class ImageConverterDarwin implements ImageConverterPlatform {
|
final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
const ImageConverterDarwin();
|
const ImageConverterDarwin();
|
||||||
|
|
@ -11,5 +12,6 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async => throw UnimplementedError();
|
}) async => throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'output_format.dart';
|
import 'output_format.dart';
|
||||||
|
import 'output_resize.dart';
|
||||||
|
|
||||||
/// Platform-specific image converter interface.
|
/// Platform-specific image converter interface.
|
||||||
///
|
///
|
||||||
|
|
@ -19,6 +20,7 @@ abstract interface class ImageConverterPlatform {
|
||||||
/// - [inputData]: Raw bytes of the image to convert
|
/// - [inputData]: Raw bytes of the image to convert
|
||||||
/// - [format]: Target [OutputFormat] (default: [OutputFormat.jpeg])
|
/// - [format]: Target [OutputFormat] (default: [OutputFormat.jpeg])
|
||||||
/// - [quality]: Compression quality 1-100 for lossy formats (default: 95)
|
/// - [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]
|
/// **Returns:** Converted image data as [Uint8List]
|
||||||
///
|
///
|
||||||
|
|
@ -30,6 +32,7 @@ abstract interface class ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) {
|
}) {
|
||||||
throw UnimplementedError('convert() has not been implemented.');
|
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/image_converter_platform_interface.dart';
|
||||||
import 'package:platform_image_converter/src/output_format.dart';
|
import 'package:platform_image_converter/src/output_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
|
|
||||||
final class ImageConverterWeb implements ImageConverterPlatform {
|
final class ImageConverterWeb implements ImageConverterPlatform {
|
||||||
const ImageConverterWeb();
|
const ImageConverterWeb();
|
||||||
|
|
@ -11,5 +12,6 @@ final class ImageConverterWeb implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async => throw UnimplementedError();
|
}) 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/image_converter_platform_interface.dart';
|
||||||
import 'package:platform_image_converter/src/output_format.dart';
|
import 'package:platform_image_converter/src/output_format.dart';
|
||||||
|
import 'package:platform_image_converter/src/output_resize.dart';
|
||||||
import 'package:web/web.dart';
|
import 'package:web/web.dart';
|
||||||
|
|
||||||
/// Web image converter using Canvas API.
|
/// Web image converter using Canvas API.
|
||||||
|
|
@ -40,6 +41,7 @@ final class ImageConverterWeb implements ImageConverterPlatform {
|
||||||
required Uint8List inputData,
|
required Uint8List inputData,
|
||||||
OutputFormat format = OutputFormat.jpeg,
|
OutputFormat format = OutputFormat.jpeg,
|
||||||
int quality = 100,
|
int quality = 100,
|
||||||
|
ResizeMode resizeMode = const OriginalResizeMode(),
|
||||||
}) async {
|
}) async {
|
||||||
final img = HTMLImageElement();
|
final img = HTMLImageElement();
|
||||||
final decodeCompeleter = Completer<void>();
|
final decodeCompeleter = Completer<void>();
|
||||||
|
|
@ -56,10 +58,72 @@ final class ImageConverterWeb implements ImageConverterPlatform {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
final canvas = HTMLCanvasElement();
|
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;
|
final ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0, destWidth, destHeight);
|
||||||
|
|
||||||
final encodeCompleter = Completer<Blob>();
|
final encodeCompleter = Completer<Blob>();
|
||||||
final type = switch (format) {
|
final type = switch (format) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue