diff --git a/lib/core/enums/image_format.dart b/lib/core/enums/image_format.dart index b0f81771..a61f49dd 100644 --- a/lib/core/enums/image_format.dart +++ b/lib/core/enums/image_format.dart @@ -1,6 +1,7 @@ enum ImageFormat { png('png'), jpg('jpg'), + ora('ora'), catrobatImage('catrobat-image'); const ImageFormat(this.extension); diff --git a/lib/core/models/image_from_file.dart b/lib/core/models/image_from_file.dart index 0d865c5b..c738471d 100644 --- a/lib/core/models/image_from_file.dart +++ b/lib/core/models/image_from_file.dart @@ -5,14 +5,17 @@ import 'package:paintroid/core/models/catrobat_image.dart'; class ImageFromFile { final Image? rasterImage; final CatrobatImage? catrobatImage; + final List? oraImageLayers; const ImageFromFile.catrobatImage( CatrobatImage image, { Image? backgroundImage, }) : catrobatImage = image, + oraImageLayers = null, rasterImage = backgroundImage; const ImageFromFile.rasterImage(Image image) : rasterImage = image, + oraImageLayers = null, catrobatImage = null; } diff --git a/lib/core/models/image_meta_data.dart b/lib/core/models/image_meta_data.dart index bcf0d963..2be6b094 100644 --- a/lib/core/models/image_meta_data.dart +++ b/lib/core/models/image_meta_data.dart @@ -28,3 +28,7 @@ class CatrobatImageMetaData extends ImageMetaData { const CatrobatImageMetaData(String name) : super(name, ImageFormat.catrobatImage); } + +class OraMetaData extends ImageMetaData { + const OraMetaData(String name) : super(name, ImageFormat.ora); +} diff --git a/lib/core/models/ora_image.dart b/lib/core/models/ora_image.dart new file mode 100644 index 00000000..2c3d4b1c --- /dev/null +++ b/lib/core/models/ora_image.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:image/image.dart' as img; +import 'package:archive/archive.dart'; + +class OraImage { + final int width; + final int height; + final List layers; + final String xmlMetadata; + + OraImage({ + required this.width, + required this.height, + required this.layers, + required this.xmlMetadata, + }); + + Uint8List toBytes() { + final archive = Archive(); + + final mimetypeContent = utf8.encode('image/openraster'); + archive.addFile( + ArchiveFile('mimetype', mimetypeContent.length, mimetypeContent) + ..compress = false, + ); + + for (int i = 0; i < layers.length; i++) { + final layer = layers[i]; + final encoder = img.PngEncoder(); + final layerData = encoder.encodeImage(layer); + archive.addFile( + ArchiveFile('data/layer_$i.png', layerData.length, layerData)); + } + + final encodedXml = utf8.encode(xmlMetadata); + archive.addFile(ArchiveFile('stack.xml', encodedXml.length, encodedXml)); + + final zipEncoder = ZipEncoder(); + return Uint8List.fromList(zipEncoder.encode(archive)!); + } + + static String generateXmlMetadataForOra( + List layers, int width, int height) { + var buffer = StringBuffer(); + buffer.writeln(''); + buffer.writeln(''); + buffer.writeln(' '); + + for (int i = 0; i < layers.length; i++) { + final layerName = 'Layer $i'; + final layerSrc = 'data/layer_$i.png'; + buffer.writeln( + ' '); + } + + buffer.writeln(' '); + buffer.writeln(''); + return buffer.toString(); + } +} diff --git a/lib/core/models/process_ora.dart b/lib/core/models/process_ora.dart new file mode 100644 index 00000000..4c853825 --- /dev/null +++ b/lib/core/models/process_ora.dart @@ -0,0 +1,88 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:archive/archive.dart'; +import 'package:image/image.dart' as img; +import 'package:xml/xml.dart' as xml; +import 'package:paintroid/core/models/loggable_mixin.dart'; + +class ProcessOra with LoggableMixin { + Future> processOraFile(Archive archive) async { + List layers = []; + ArchiveFile? stackXmlFile = archive.findFile('stack.xml'); + + if (stackXmlFile == null) { + logger.severe('Error: stack.xml not found in ORA file.'); + return layers; + } + + if (stackXmlFile.content == null || stackXmlFile.content is! List) { + logger.severe('Error: stack.xml content is invalid.'); + return layers; + } + + String xmlContent = String.fromCharCodes(stackXmlFile.content as List); + xml.XmlDocument document; + try { + document = xml.XmlDocument.parse(xmlContent); + } catch (e, s) { + logger.severe('Error parsing stack.xml', e, s); + return layers; + } + + var imageElement = document.rootElement; + var stackElement = imageElement.findElements('stack').firstOrNull; + + if (stackElement == null) { + logger.severe('Error: element not found in stack.xml.'); + return layers; + } + + for (var layerElement in stackElement.findElements('layer')) { + String? layerSrc = layerElement.getAttribute('src'); + if (layerSrc == null) { + logger + .warning('Warning: Layer element missing src attribute. Skipping.'); + continue; + } + + ArchiveFile? imageFile = archive.findFile(layerSrc); + if (imageFile == null || !imageFile.isFile) { + logger.warning( + 'Warning: Image file $layerSrc not found in archive for a layer. Skipping.'); + continue; + } + + if (imageFile.content == null || imageFile.content is! List) { + logger.warning( + 'Warning: Image file $layerSrc content is invalid. Skipping.'); + continue; + } + + img.Image? decodedImage; + try { + decodedImage = img.decodeImage(imageFile.content as List); + if (decodedImage == null) { + logger.warning( + 'Warning: decodeImage returned null for $layerSrc. Skipping.'); + continue; + } + } catch (e, s) { + logger.severe('Error decoding image $layerSrc. Skipping.', e, s); + continue; + } + + ui.Image layerUiImage = await convertImgImageToUiImage(decodedImage); + layers.add(layerUiImage); + } + + return layers; + } + + Future convertImgImageToUiImage(img.Image image) async { + List pngBytes = img.encodePng(image); + + final codec = await ui.instantiateImageCodec(Uint8List.fromList(pngBytes)); + final frame = await codec.getNextFrame(); + return frame.image; + } +} diff --git a/lib/core/providers/object/file_service.dart b/lib/core/providers/object/file_service.dart index b530243e..fa5fbe69 100644 --- a/lib/core/providers/object/file_service.dart +++ b/lib/core/providers/object/file_service.dart @@ -52,13 +52,15 @@ class FileService with LoggableMixin implements IFileService { @override Future> save(String filename, Uint8List data) async { try { - final saveDirectory = await FilePicker.platform.getDirectoryPath(); - if (saveDirectory == null) { + final savePath = await FilePicker.platform.saveFile( + dialogTitle: 'Save As', + fileName: filename, + bytes: data, + ); + if (savePath == null) { return const Result.err(SaveImageFailure.userCancelled); } - final file = - await File('$saveDirectory/$filename').create(recursive: true); - return Result.ok(await file.writeAsBytes(data)); + return Result.ok(File(savePath)); } catch (err, stacktrace) { logger.severe('Could not save file', err, stacktrace); return const Result.err(SaveImageFailure.unidentified); diff --git a/lib/core/providers/object/io_handler.dart b/lib/core/providers/object/io_handler.dart index 710391a9..f7c74cac 100644 --- a/lib/core/providers/object/io_handler.dart +++ b/lib/core/providers/object/io_handler.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,12 +12,15 @@ import 'package:paintroid/core/enums/image_format.dart'; import 'package:paintroid/core/enums/image_location.dart'; import 'package:paintroid/core/models/catrobat_image.dart'; import 'package:paintroid/core/models/image_meta_data.dart'; +import 'package:paintroid/core/models/loggable_mixin.dart'; +import 'package:paintroid/core/models/ora_image.dart'; import 'package:paintroid/core/providers/object/file_service.dart'; import 'package:paintroid/core/providers/object/image_service.dart'; import 'package:paintroid/core/providers/object/load_image_from_file_manager.dart'; import 'package:paintroid/core/providers/object/load_image_from_photo_library.dart'; import 'package:paintroid/core/providers/object/render_image_for_export.dart'; import 'package:paintroid/core/providers/object/save_as_catrobat_image.dart'; +import 'package:paintroid/core/providers/object/save_as_ora_image.dart'; import 'package:paintroid/core/providers/object/save_as_raster_image.dart'; import 'package:paintroid/core/providers/state/app_bar_provider.dart'; import 'package:paintroid/core/providers/state/canvas_state_provider.dart'; @@ -27,14 +32,13 @@ import 'package:paintroid/ui/shared/dialogs/load_image_dialog.dart'; import 'package:paintroid/ui/shared/dialogs/save_image_dialog.dart'; import 'package:paintroid/ui/utils/toast_utils.dart'; -class IOHandler { +class IOHandler with LoggableMixin { final Ref ref; - const IOHandler(this.ref); + IOHandler(this.ref); static final provider = Provider((ref) => IOHandler(ref)); - /// Returns [true] if the image was saved successfully Future saveImage(BuildContext context) async { final workspaceStateNotifier = ref.read(workspaceStateProvider.notifier); final imageMetaData = await showSaveImageDialog(context, false); @@ -62,9 +66,6 @@ class IOHandler { return savedFile; } - /// Returns [true] if - - /// - There was no unsaved work, or - /// - The unsaved work was saved successfully Future handleUnsavedChanges(BuildContext context, State state) async { final workspaceStateNotifier = ref.read(workspaceStateProvider.notifier); if (!workspaceStateNotifier.hasSavedLastWork) { @@ -79,7 +80,6 @@ class IOHandler { return true; } - /// Returns [true] if the image was loaded successfully Future loadImage( BuildContext context, State state, bool unsavedChanges) async { if (unsavedChanges) { @@ -101,7 +101,6 @@ class IOHandler { } } - /// Returns [true] if a new image canvas was created successfully Future newImage(BuildContext context, State state) async { final shouldContinue = await handleUnsavedChanges(context, state); if (!shouldContinue) return false; @@ -178,10 +177,65 @@ class IOHandler { } else if (imageData is CatrobatImageMetaData) { final savedFile = await _saveAsCatrobatImage(imageData, false); isImageSaved = (savedFile != null); + } else if (imageData is OraMetaData) { + isImageSaved = await _saveAsOraImage(imageData); } return isImageSaved; } + Future convertUiImageToImgImage(ui.Image uiImage) async { + final ByteData? byteData = + await uiImage.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + const message = 'Failed to convert ui.Image to PNG byte data.'; + logger.severe(message); + throw Exception(message); + } + final Uint8List pngBytes = byteData.buffer.asUint8List(); + final img.Image? decodedImage = img.decodePng(pngBytes); + if (decodedImage == null) { + const message = 'Failed to decode PNG bytes to img.Image.'; + logger.severe(message); + throw Exception(message); + } + return decodedImage; + } + + Future _saveAsOraImage(OraMetaData imageData) async { + final oraImageService = ref.read(SaveAsOraImage.provider); + + final ui.Image imageToExport = await ref + .read(RenderImageForExport.provider) + .call(keepTransparency: true); + + final imgWidth = imageToExport.width; + final imgHeight = imageToExport.height; + + img.Image layer = await convertUiImageToImgImage(imageToExport); + + final oraImage = OraImage( + width: imgWidth, + height: imgHeight, + layers: [layer], + xmlMetadata: + OraImage.generateXmlMetadataForOra([layer], imgWidth, imgHeight), + ); + + final fileName = '${imageData.name}.ora'; + final result = await oraImageService.call(oraImage, fileName); + + return result.match( + (file) { + ToastUtils.showShortToast(message: 'Saved successfully'); + return true; + }, + (error) { + ToastUtils.showShortToast(message: error.message); + return false; + }, + ); + } + Future _saveAsRasterImage(ImageMetaData imageData) async { final image = await ref .read(RenderImageForExport.provider) diff --git a/lib/core/providers/object/load_image_from_file_manager.dart b/lib/core/providers/object/load_image_from_file_manager.dart index 849224ad..5a8361e4 100644 --- a/lib/core/providers/object/load_image_from_file_manager.dart +++ b/lib/core/providers/object/load_image_from_file_manager.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'dart:ui'; +import 'dart:ui' as ui; +import 'package:archive/archive.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:oxidized/oxidized.dart'; @@ -9,6 +10,7 @@ import 'package:oxidized/oxidized.dart'; import 'package:paintroid/core/models/catrobat_image.dart'; import 'package:paintroid/core/models/image_from_file.dart'; import 'package:paintroid/core/models/loggable_mixin.dart'; +import 'package:paintroid/core/models/process_ora.dart'; import 'package:paintroid/core/providers/object/file_service.dart'; import 'package:paintroid/core/providers/object/image_service.dart'; import 'package:paintroid/core/providers/object/permission_service.dart'; @@ -61,12 +63,23 @@ class LoadImageFromFileManager with LoggableMixin { case 'catrobat-image': Uint8List bytes = await file.readAsBytes(); CatrobatImage catrobatImage = CatrobatImage.fromBytes(bytes); - Image? backgroundImage = + ui.Image? backgroundImage = await rebuildBackgroundImage(catrobatImage); return Result.ok(ImageFromFile.catrobatImage( catrobatImage, backgroundImage: backgroundImage, )); + case 'ora': + Uint8List bytes = await file.readAsBytes(); + Archive archive = ZipDecoder().decodeBytes(bytes); + ProcessOra processOra = ProcessOra(); + List layers = await processOra.processOraFile(archive); + + if (layers.isNotEmpty) { + return Result.ok(ImageFromFile.rasterImage(layers.first)); + } else { + return const Result.err(LoadImageFailure.invalidImage); + } default: return const Result.err(LoadImageFailure.invalidImage); } @@ -80,7 +93,7 @@ class LoadImageFromFileManager with LoggableMixin { }); } - Future rebuildBackgroundImage(CatrobatImage catrobatImage) async { + Future rebuildBackgroundImage(CatrobatImage catrobatImage) async { if (catrobatImage.backgroundImage.isNotEmpty) { final backgroundImageData = base64Decode(catrobatImage.backgroundImage); final result = diff --git a/lib/core/providers/object/save_as_ora_image.dart b/lib/core/providers/object/save_as_ora_image.dart new file mode 100644 index 00000000..094d2255 --- /dev/null +++ b/lib/core/providers/object/save_as_ora_image.dart @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:oxidized/oxidized.dart'; +import 'package:paintroid/core/models/ora_image.dart'; +import 'package:paintroid/core/providers/object/file_service.dart'; +import 'package:paintroid/core/providers/object/permission_service.dart'; +import 'package:paintroid/core/utils/failure.dart'; +import 'package:paintroid/core/utils/save_image_failure.dart'; + +class SaveAsOraImage { + final IFileService _fileService; + final IPermissionService _permissionService; + + SaveAsOraImage(this._fileService, this._permissionService); + + static final provider = Provider((ref) { + final fileService = ref.watch(IFileService.provider); + final permissionService = ref.watch(IPermissionService.provider); + return SaveAsOraImage(fileService, permissionService); + }); + + Future> call(OraImage image, String fileName) async { + if (!(await _permissionService.requestAccessToSharedFileStorage())) { + return const Result.err(SaveImageFailure.permissionDenied); + } + + final bytes = image.toBytes(); + return _fileService.save(fileName, bytes); + } +} diff --git a/lib/ui/shared/dialogs/save_image_dialog.dart b/lib/ui/shared/dialogs/save_image_dialog.dart index 3e6280bf..80572309 100644 --- a/lib/ui/shared/dialogs/save_image_dialog.dart +++ b/lib/ui/shared/dialogs/save_image_dialog.dart @@ -50,6 +50,9 @@ class _SaveImageDialogState extends State { case ImageFormat.catrobatImage: data = CatrobatImageMetaData(nameFieldController.text); break; + case ImageFormat.ora: + data = OraMetaData(nameFieldController.text); + break; } Navigator.of(context).pop(data); } diff --git a/lib/ui/shared/image_format_info.dart b/lib/ui/shared/image_format_info.dart index 481f7f71..2d040eac 100644 --- a/lib/ui/shared/image_format_info.dart +++ b/lib/ui/shared/image_format_info.dart @@ -24,6 +24,10 @@ extension on ImageFormat { return const TextSpan( text: 'Pocket Paint\'s native image format. ' 'This format remembers commands and layers.'); + case ImageFormat.ora: + return const TextSpan( + text: + 'OpenRaster format. Supports layers and various attributes like opacity and visibility for each layer.'); } } } diff --git a/pubspec.lock b/pubspec.lock index b831864b..04e347f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -31,7 +31,7 @@ packages: source: hosted version: "0.11.3" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d @@ -900,10 +900,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 url: "https://pub.dev" source: hosted - version: "9.4.6" + version: "9.4.7" permission_handler_html: dependency: transitive description: @@ -1502,7 +1502,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 diff --git a/pubspec.yaml b/pubspec.yaml index 6e060f09..22644fcd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: file_picker: ^8.3.5 floor: ^1.2.0 sqflite: ^2.3.0 + archive: ^3.4.10 + xml: ^6.1.0 colorpicker: path: packages/colorpicker