feat: Add resize option

This commit is contained in:
Koji Wakamiya 2025-12-11 22:28:23 +09:00
parent 708c486a09
commit b8c9ca9d9b
No known key found for this signature in database
17 changed files with 579 additions and 77 deletions

109
README.md
View File

@ -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 200x200
final resizedData = await ImageConverter.convert(
inputData: imageData,
format: OutputFormat.png,
resizeMode: const FitResizeMode(width: 200, height: 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({required int width, required int height})`**: Fits the image within the specified dimensions while maintaining the aspect ratio. If the image is smaller than the specified dimensions, it will not be scaled up.
## Implementation Details ## 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.

View File

@ -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,84 @@ 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));
});
}); });
group('File size consistency tests', () { group('File size consistency tests', () {

View File

@ -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, height: 1 << 30),
(null, final h?) => FitResizeMode(width: 1 << 30, 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'),

View File

@ -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:

View File

@ -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

View File

@ -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',
}), }),

View File

@ -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,6 +66,7 @@ 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) {
@ -70,6 +74,7 @@ class ImageConverter {
inputData: inputData, inputData: inputData,
format: format, format: format,
quality: quality, quality: quality,
resizeMode: resizeMode,
platform: defaultTargetPlatform, platform: defaultTargetPlatform,
)); ));
} else { } else {
@ -78,6 +83,7 @@ class ImageConverter {
inputData: inputData, inputData: inputData,
format: format, format: format,
quality: quality, quality: quality,
resizeMode: resizeMode,
); );
} }
} }
@ -103,6 +109,7 @@ Future<Uint8List> _convertInIsolate(
Uint8List inputData, Uint8List inputData,
OutputFormat format, OutputFormat format,
int quality, int quality,
ResizeMode resizeMode,
TargetPlatform platform, TargetPlatform platform,
}) })
request, request,
@ -112,5 +119,6 @@ Future<Uint8List> _convertInIsolate(
inputData: request.inputData, inputData: request.inputData,
format: request.format, format: request.format,
quality: request.quality, quality: request.quality,
resizeMode: request.resizeMode,
); );
} }

View File

@ -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,68 @@ 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();
if (originalWidth <= width && originalHeight <= height) {
bitmapToCompress = originalBitmap;
} else {
final aspectRatio = originalWidth / originalHeight;
var newWidth = width.toDouble();
var newHeight = newWidth / aspectRatio;
if (newHeight > height) {
newHeight = height.toDouble();
newWidth = newHeight * aspectRatio;
}
scaledBitmap = Bitmap.createScaledBitmap(
originalBitmap,
newWidth.round(),
newHeight.round(),
true, // filter
);
bitmapToCompress = scaledBitmap;
}
}
if (bitmapToCompress == null) {
// This should not happen if originalBitmap is valid
throw Exception('Bitmap could not be prepared for compression.');
}
compressFormat = switch (format) { 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 +110,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 +127,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();

View File

@ -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();
} }

View File

@ -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>;

View File

@ -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,42 @@ 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);
if (originalWidth <= width && originalHeight <= height) {
imageToEncode = originalImage;
} else {
final aspectRatio = originalWidth / originalHeight;
var newWidth = width.toDouble();
var newHeight = newWidth / aspectRatio;
if (newHeight > height) {
newHeight = height.toDouble();
newWidth = newHeight * aspectRatio;
}
imageToEncode = _resizeImage(
originalImage,
newWidth.round(),
newHeight.round(),
);
}
}
if (imageToEncode == nullptr) {
throw Exception('Failed to prepare image for encoding.');
}
outputData = CFDataCreateMutable(kCFAllocatorDefault, 0); 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 +138,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 +160,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 +201,53 @@ final class ImageConverterDarwin implements ImageConverterPlatform {
); );
}); });
} }
CGImageRef _resizeImage(CGImageRef originalImage, int width, int height) {
CGColorSpaceRef? colorSpace;
CGContextRef? context;
try {
colorSpace = CGImageGetColorSpace(originalImage);
if (colorSpace == nullptr) {
throw Exception('Failed to get color space from image.');
}
final bitsPerComponent = CGImageGetBitsPerComponent(originalImage);
final bitmapInfo = CGImageGetBitmapInfo(originalImage);
context = CGBitmapContextCreate(
nullptr,
width,
height,
bitsPerComponent,
0, // bytesPerRow (0 means calculate automatically)
colorSpace,
bitmapInfo,
);
if (context == nullptr) {
throw Exception('Failed to create bitmap context for resizing.');
}
CGContextSetInterpolationQuality(
context,
CGInterpolationQuality.kCGInterpolationHigh,
);
final rect = Struct.create<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());
}
}
} }

View File

@ -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();
} }

View File

@ -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.');
} }

View File

@ -0,0 +1,36 @@
/// A sealed class representing different ways to resize an image.
sealed class ResizeMode {
const ResizeMode();
}
/// A resize mode that keeps the original dimensions of the image.
class OriginalResizeMode extends ResizeMode {
const OriginalResizeMode();
}
/// A resize mode that resizes the image to exact dimensions,
/// possibly changing the aspect ratio.
class ExactResizeMode extends ResizeMode {
const ExactResizeMode({required this.width, required this.height});
/// The target width for the resized image.
final int width;
/// The target height for the resized image.
final int height;
}
/// A resize mode that fits the image within the specified dimensions while
/// maintaining the aspect ratio.
///
/// If the image is smaller than the specified dimensions, it will not be
/// scaled up.
class FitResizeMode extends ResizeMode {
const FitResizeMode({required this.width, required this.height});
/// The maximum width for the resized image.
final int width;
/// The maximum height for the resized image.
final int height;
}

View File

@ -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();
} }

View File

@ -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,40 @@ 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):
if (img.width <= width && img.height <= height) {
destWidth = img.width;
destHeight = img.height;
} else {
final aspectRatio = img.width / img.height;
var newWidth = width.toDouble();
var newHeight = newWidth / aspectRatio;
if (newHeight > height) {
newHeight = height.toDouble();
newWidth = newHeight * aspectRatio;
}
destWidth = newWidth.round();
destHeight = newHeight.round();
}
}
canvas.width = destWidth;
canvas.height = destHeight;
final ctx = canvas.getContext('2d') as CanvasRenderingContext2D; 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) {