diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 2693c6ab..1a373b77 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 3338a5fe..8ef023a4 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -48,6 +48,7 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -88,6 +89,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -204,7 +206,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -362,7 +364,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -495,7 +497,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -546,7 +548,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -644,7 +646,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index c0f01d89..8091963a 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -47,5 +47,10 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/example/lib/view_models/pdf_combiner_view_model.dart b/example/lib/view_models/pdf_combiner_view_model.dart index c3cd95d9..0b25b484 100644 --- a/example/lib/view_models/pdf_combiner_view_model.dart +++ b/example/lib/view_models/pdf_combiner_view_model.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:pdf_combiner/exception/pdf_combiner_exception.dart'; import 'package:pdf_combiner/models/merge_input.dart'; @@ -31,6 +32,9 @@ class PdfCombinerViewModel { selectedFiles += result.files.map((file) => MergeInput.bytes(file.bytes!)).toList(); break; + case MergeInputType.url: + // Currently not supported in file picker + break; } } @@ -51,6 +55,9 @@ class PdfCombinerViewModel { ), ); break; + case MergeInputType.url: + // Currently not supported in drag and drop + break; } outputFiles = []; @@ -155,4 +162,39 @@ class PdfCombinerViewModel { void removeFileAt(int index) { selectedFiles.removeAt(index); } + +/// Function to format bytes into a human-readable string + String formatBytes(int bytes) { + if (bytes >= 1e9) return "${(bytes / 1e9).toStringAsFixed(2)} GB"; + if (bytes >= 1e6) return "${(bytes / 1e6).toStringAsFixed(2)} MB"; + if (bytes >= 1e3) return "${(bytes / 1e3).toStringAsFixed(2)} KB"; + return "$bytes B"; + } + +/// Function to get the file size from a URL + Future getUrlFileSize(String url) async { + final uri = Uri.parse(url); + final client = http.Client(); + try { + final head = await client.head(uri); + if (head.statusCode >= 200 && head.statusCode < 300) { + final cl = head.headers['content-length']; + if (cl != null) return int.tryParse(cl); + } + + final range = await client.get(uri, headers: {'Range': 'bytes=0-0'}); + if (range.statusCode == 206) { + final cr = range.headers['content-range']; + if (cr != null) { + final parts = cr.split('/'); + if (parts.length == 2) return int.tryParse(parts[1]); + } + } + } catch (_) { + return null; + } finally { + client.close(); + } + return null; + } } diff --git a/example/lib/views/pdf_combiner_screen.dart b/example/lib/views/pdf_combiner_screen.dart index 3c08aa21..1b02c3cd 100644 --- a/example/lib/views/pdf_combiner_screen.dart +++ b/example/lib/views/pdf_combiner_screen.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_magic_number/file_magic_number.dart'; import 'package:flutter/material.dart'; @@ -8,7 +10,7 @@ import 'package:pdf_combiner/models/merge_input.dart'; import 'package:pdf_combiner_example/utils/uint8list_extension.dart'; import 'package:pdf_combiner_example/views/widgets/file_type_dialog.dart'; import 'package:pdf_combiner_example/views/widgets/file_type_icon.dart'; - +import 'package:http/http.dart' as http; import '../view_models/pdf_combiner_view_model.dart'; extension on MergeInput { @@ -18,6 +20,8 @@ extension on MergeInput { return p.basename(path ?? ''); case MergeInputType.bytes: return 'File in bytes $index'; + case MergeInputType.url: + return url ?? 'File from URL $index'; } } } @@ -56,10 +60,19 @@ class _PdfCombinerScreenState extends State { children: [ DropTarget( onDragDone: (details) async { - final fileType = - await showFileTypeDialog(context); // Show dialog - if (fileType == null) return; - await _viewModel.addFilesDragAndDrop(fileType, details.files); + final selection = + await showFileTypeDialog(context, isDrag: true); // Show dialog + if (selection == null) return; + if (selection.type == MergeInputType.url) { + if (selection.url != null && selection.url!.isNotEmpty) { + _viewModel.selectedFiles += [ + MergeInput.url(selection.url!) + ]; + } + } else { + await _viewModel.addFilesDragAndDrop( + selection.type, details.files); + } setState(() {}); }, child: (_viewModel.isEmpty()) @@ -188,6 +201,9 @@ class _PdfCombinerScreenState extends State { .getBytesFromPathOrBlob(_viewModel .selectedFiles[index] .toString()), + MergeInputType.url => _viewModel.getUrlFileSize( + _viewModel.selectedFiles[index] + .toString()), }, builder: (context, snapshot) { if (snapshot.connectionState == @@ -197,7 +213,17 @@ class _PdfCombinerScreenState extends State { } else if (snapshot.hasError) { return const Icon(Icons.error); } else { - return Text(snapshot.data?.size() ?? + final input = + _viewModel.selectedFiles[index]; + if (input.type == + MergeInputType.url) { + final len = snapshot.data as int?; + return Text(len != null + ? _viewModel.formatBytes(len) + : "Unknown Size"); + } + final snapshotUint = snapshot.data as Uint8List?; + return Text(snapshotUint?.size() ?? "Unknown Size"); } }), @@ -270,9 +296,16 @@ class _PdfCombinerScreenState extends State { // Function to pick PDF files from the device Future _pickFiles() async { - final fileType = await showFileTypeDialog(context); - if (fileType == null) return; - await _viewModel.pickFiles(fileType); + final selection = await showFileTypeDialog(context); + if (selection == null) return; + if (selection.type == MergeInputType.url) { + if (selection.url != null && selection.url!.isNotEmpty) { + _viewModel.selectedFiles += [MergeInput.url(selection.url!)]; + } + setState(() {}); + } else { + await _viewModel.pickFiles(selection.type); + } setState(() {}); } @@ -353,6 +386,8 @@ class _PdfCombinerScreenState extends State { return; case MergeInputType.bytes: return; + case MergeInputType.url: + return; } } } diff --git a/example/lib/views/widgets/file_type_dialog.dart b/example/lib/views/widgets/file_type_dialog.dart index 2fdfe916..a9aa35b8 100644 --- a/example/lib/views/widgets/file_type_dialog.dart +++ b/example/lib/views/widgets/file_type_dialog.dart @@ -1,22 +1,124 @@ import 'package:flutter/material.dart'; import 'package:pdf_combiner/models/merge_input.dart'; -Future showFileTypeDialog(BuildContext context) async => - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: const Text('File Type'), - content: const Text('Select a file type'), - actions: [ - TextButton( - child: const Text('Path'), - onPressed: () => Navigator.of(context).pop(MergeInputType.path), - ), - TextButton( - child: Text('Bytes'), - onPressed: () => Navigator.of(context).pop(MergeInputType.bytes), +class FileTypeSelection { + final MergeInputType type; + final String? url; + + const FileTypeSelection(this.type, {this.url}); +} + +class RadioGroupItem { + final T value; + final String label; + const RadioGroupItem(this.value, this.label); +} + +class RadioGroupFileType extends StatelessWidget { + final T groupValue; + final ValueChanged onChanged; + final List> items; + + const RadioGroupFileType({ + super.key, + required this.groupValue, + required this.onChanged, + required this.items, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: items.map((item) { + return InkWell( + onTap: () => onChanged(item.value), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: RadioGroup( + groupValue: groupValue, + onChanged: onChanged, + child: ListTile( + title: Text(item.label), + leading: Radio(toggleable: true, value: item.value), + ), + ), ), - ], - ), + ); + }).toList(), ); + } +} + +Future showFileTypeDialog(BuildContext context, + {bool isDrag = false}) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + MergeInputType selected = MergeInputType.path; + final controller = TextEditingController(); + + return StatefulBuilder(builder: (context, setState) { + final isUrlSelected = selected == MergeInputType.url; + final canAccept = (!isUrlSelected || controller.text.trim().isNotEmpty); + + return AlertDialog( + title: const Text('File Type'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioGroupFileType( + groupValue: selected, + onChanged: (v) => setState(() => selected = v!), + items: isDrag + ? const [ + RadioGroupItem(MergeInputType.path, 'Path'), + RadioGroupItem(MergeInputType.bytes, 'Bytes'), + ] + : const [ + RadioGroupItem(MergeInputType.path, 'Path'), + RadioGroupItem(MergeInputType.bytes, 'Bytes'), + RadioGroupItem(MergeInputType.url, 'Url'), + ], + ), + if (isUrlSelected) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextField( + controller: controller, + autofocus: true, + decoration: + const InputDecoration(hintText: 'https://...'), + keyboardType: TextInputType.url, + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(null), + ), + TextButton( + onPressed: canAccept + ? () { + final url = controller.text.trim(); + if (selected == MergeInputType.url) { + Navigator.of(context) + .pop(FileTypeSelection(selected, url: url)); + } else { + Navigator.of(context).pop(FileTypeSelection(selected)); + } + } + : null, + child: const Text('Accept'), + ), + ], + ); + }); + }, + ); +} diff --git a/example/lib/views/widgets/file_type_icon.dart b/example/lib/views/widgets/file_type_icon.dart index 798d4b46..3141eb63 100644 --- a/example/lib/views/widgets/file_type_icon.dart +++ b/example/lib/views/widgets/file_type_icon.dart @@ -2,20 +2,22 @@ import 'package:file_magic_number/file_magic_number.dart'; import 'package:flutter/material.dart'; import 'package:open_file/open_file.dart'; import 'package:pdf_combiner/models/merge_input.dart'; +import 'package:pdf_combiner/utils/string_extension.dart'; class FileTypeIcon extends StatelessWidget { final MergeInput input; const FileTypeIcon({super.key, required this.input}); @override - Widget build(BuildContext context) { - return TextButton( + Widget build(BuildContext context) => TextButton( onPressed: () { switch (input.type) { case MergeInputType.path: OpenFile.open(input.path!); case MergeInputType.bytes: null; + case MergeInputType.url: + null; } }, child: FutureBuilder( @@ -24,6 +26,7 @@ class FileTypeIcon extends StatelessWidget { FileMagicNumber.detectFileTypeFromBytes(input.bytes!)), MergeInputType.path => FileMagicNumber.detectFileTypeFromPathOrBlob(input.path!), + MergeInputType.url => Future.value(input.url.stringToMagicType), }, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -46,5 +49,6 @@ class FileTypeIcon extends StatelessWidget { } }), ); - } + + } diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index afdd4995..494fdde7 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements index 976176a5..6dd5c931 100644 --- a/example/macos/Runner/Release.entitlements +++ b/example/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/example/pubspec.lock b/example/pubspec.lock index 7097c386..e237d73b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -282,6 +282,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index afc73407..2d6f3dbd 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: open_file: ^3.5.11 platform_detail: ^5.1.0 file_magic_number: ^1.4.2 + http: ^1.6.0 dev_dependencies: integration_test: diff --git a/lib/models/merge_input.dart b/lib/models/merge_input.dart index 946c7b1b..4611b6e0 100644 --- a/lib/models/merge_input.dart +++ b/lib/models/merge_input.dart @@ -4,6 +4,7 @@ import 'dart:typed_data' show Uint8List; enum MergeInputType { path, bytes, + url, } /// A class representing an input for merging PDFs. @@ -11,11 +12,12 @@ enum MergeInputType { /// It can be created from a file path or a byte array. class MergeInput { final String? path; + final String? url; final Uint8List? bytes; final MergeInputType type; - const MergeInput(this.type, {this.path, this.bytes}) - : assert((path != null) != (bytes != null)); + const MergeInput(this.type, {this.path, this.bytes, this.url}) + : assert(((path != null) != (bytes != null))!= (url != null)); /// Creates a [MergeInput] from a file path. factory MergeInput.path(String path) => @@ -24,6 +26,10 @@ class MergeInput { /// Creates a [MergeInput] from a byte array. factory MergeInput.bytes(Uint8List bytes) => MergeInput(MergeInputType.bytes, bytes: bytes); + + /// Creates a [MergeInput] from a url string. + factory MergeInput.url(String url) => + MergeInput(MergeInputType.url, url: url); @override String toString() { @@ -32,6 +38,8 @@ class MergeInput { return path!; case MergeInputType.bytes: return bytes!.toString(); + case MergeInputType.url: + return url!; } } } diff --git a/lib/pdf_combiner.dart b/lib/pdf_combiner.dart index 51259bb3..06e38b8c 100644 --- a/lib/pdf_combiner.dart +++ b/lib/pdf_combiner.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:pdf_combiner/exception/pdf_combiner_exception.dart'; import 'package:pdf_combiner/isolates/images_from_pdf_isolate.dart'; import 'package:pdf_combiner/models/merge_input.dart'; @@ -49,17 +50,18 @@ class PdfCombiner { required List inputs, required String outputPath, }) async { + final List newInputs = await DocumentUtils.conversionUrlInputsToPaths(inputs); List temporalPaths = []; - final List mutablePaths = List.from(inputs); - if (inputs.isEmpty) { + final List mutablePaths = List.from(newInputs); + if (newInputs.isEmpty) { throw (PdfCombinerException( PdfCombinerMessages.emptyParameterMessage("inputs"))); } else if (outputPath.trim().isEmpty) { throw (PdfCombinerException( PdfCombinerMessages.emptyParameterMessage("outputPath"))); } else { - for (int i = 0; i < inputs.length; i++) { - final input = inputs[i]; + for (int i = 0; i < newInputs.length; i++) { + final input = newInputs[i]; final isPDF = await DocumentUtils.isPDF(input); final isImage = await DocumentUtils.isImage(input); final outputPathIsPDF = DocumentUtils.hasPDFExtension(outputPath); @@ -112,8 +114,9 @@ class PdfCombiner { required List inputs, required String outputPath, }) async { + final List newInputs = await DocumentUtils.conversionUrlInputsToPaths(inputs); final temportalFilePaths = []; - if (inputs.isEmpty) { + if (newInputs.isEmpty) { throw PdfCombinerException( PdfCombinerMessages.emptyParameterMessage("inputPaths")); } else { @@ -121,7 +124,7 @@ class PdfCombiner { bool success = true; String? path; - for (MergeInput input in inputs) { + for (MergeInput input in newInputs) { success = await DocumentUtils.isPDF(input); path = input.path; } @@ -134,7 +137,7 @@ class PdfCombiner { throw PdfCombinerException(PdfCombinerMessages.errorMessagePDF(path)); } else { final inputPaths = await Future.wait( - inputs.map( + newInputs.map( (input) async { final result = await DocumentUtils.prepareInput(input); switch (input.type) { @@ -143,6 +146,8 @@ class PdfCombiner { break; case MergeInputType.path: break; + case MergeInputType.url: + break; } return result; }, @@ -190,12 +195,13 @@ class PdfCombiner { required String outputPath, PdfFromMultipleImageConfig config = const PdfFromMultipleImageConfig(), }) async { + final List newInputs = kIsWeb ? inputs : await DocumentUtils.conversionUrlInputsToPaths(inputs); final temportalFilePaths = []; final outputPathIsPDF = DocumentUtils.hasPDFExtension(outputPath); if (!outputPathIsPDF) { throw PdfCombinerException( PdfCombinerMessages.errorMessageInvalidOutputPath(outputPath)); - } else if (inputs.isEmpty) { + } else if (newInputs.isEmpty) { throw PdfCombinerException( PdfCombinerMessages.emptyParameterMessage("inputPaths")); } else { @@ -204,9 +210,9 @@ class PdfCombiner { String? path; int i = 0; - while (i < inputs.length && success) { - success = await DocumentUtils.isImage(inputs[i]); - path = inputs[i].path; + while (i < newInputs.length && success) { + success = await DocumentUtils.isImage(newInputs[i]); + path = newInputs[i].path; i++; } @@ -215,7 +221,7 @@ class PdfCombiner { PdfCombinerMessages.errorMessageImage(path ?? '')); } else { final inputPaths = await Future.wait( - inputs.map( + newInputs.map( (input) async { final result = await DocumentUtils.prepareInput(input); switch (input.type) { @@ -224,6 +230,8 @@ class PdfCombiner { break; case MergeInputType.path: break; + case MergeInputType.url: + break; } return result; }, @@ -279,20 +287,24 @@ class PdfCombiner { required String outputDirPath, ImageFromPdfConfig config = const ImageFromPdfConfig(), }) async { + final MergeInput newInput = await DocumentUtils.conversionUrlInputsToPaths([input]).then((value) => value.first); String? temportalFilePath; try { - bool success = await DocumentUtils.isPDF(input); + bool success = await DocumentUtils.isPDF(newInput); if (!success) { String inputTypeMessage; - switch (input.type) { + switch (newInput.type) { case MergeInputType.bytes: inputTypeMessage = "File in bytes"; break; case MergeInputType.path: - inputTypeMessage = input.path!; + inputTypeMessage = newInput.path!; + break; + case MergeInputType.url: + inputTypeMessage = newInput.url!; break; } @@ -300,13 +312,15 @@ class PdfCombiner { inputTypeMessage, )); } else { - final inputPath = await DocumentUtils.prepareInput(input); - switch (input.type) { + final inputPath = await DocumentUtils.prepareInput(newInput); + switch (newInput.type) { case MergeInputType.bytes: temportalFilePath = inputPath; break; case MergeInputType.path: break; + case MergeInputType.url: + break; } final response = await ImagesFromPdfIsolate.createImageFromPDF( inputPath: inputPath, diff --git a/lib/utils/document_utils_io.dart b/lib/utils/document_utils_io.dart index 415712be..dbfee90d 100644 --- a/lib/utils/document_utils_io.dart +++ b/lib/utils/document_utils_io.dart @@ -2,17 +2,24 @@ import 'dart:io'; import 'dart:math'; import 'package:file_magic_number/file_magic_number.dart'; -import 'package:path/path.dart' as p; +import 'package:pdf_combiner/exception/pdf_combiner_exception.dart'; import 'package:pdf_combiner/models/merge_input.dart'; +import 'package:path/path.dart' as p; import 'package:pdf_combiner/pdf_combiner.dart'; +import 'package:http/http.dart' as http; +import 'package:pdf_combiner/responses/pdf_combiner_messages.dart'; +import 'package:pdf_combiner/utils/string_extension.dart'; extension on MergeInputType { - String extension() { + String extension(MergeInput input) { switch (this) { case MergeInputType.path: - return '.pdf'; + return p.extension(input.path!); case MergeInputType.bytes: - return '.png'; + final magicType = FileMagicNumber.detectFileTypeFromBytes(input.bytes); + return magicType.name; + case MergeInputType.url: + return p.extension(input.url!); } } @@ -22,6 +29,8 @@ extension on MergeInputType { return 'pdf_input'; case MergeInputType.bytes: return 'image_input'; + case MergeInputType.url: + return 'url_input'; } } } @@ -64,6 +73,55 @@ class DocumentUtils { } } + /// Converts any `MergeInput.url` entries into temporary `MergeInput.path` files. + /// + /// Downloads each URL to a temporary file inside the configured temporal + /// folder and returns a new list where URLs were replaced by `MergeInput.path` + /// referencing the downloaded file. `path` and `bytes` inputs are preserved. + + static Future> conversionUrlInputsToPaths( + List inputs) async { + final outputs = []; + final tempDirPath = getTemporalFolderPath(); + final tempDir = Directory(tempDirPath); + if (!await tempDir.exists()) { + await tempDir.create(recursive: true); + } + + for (final input in inputs) { + switch (input.type) { + case MergeInputType.path: + outputs.add(input); + break; + case MergeInputType.bytes: + outputs.add(input); + break; + case MergeInputType.url: + try { + final response = await http.get(Uri.parse(input.url!)); + if (response.statusCode == 200) { + final byteInput = MergeInput.bytes(response.bodyBytes); + + final fileName = + '${byteInput.type.filenamePrefix()}_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}${byteInput.type.extension(byteInput)}'; + final tempPath = p.join(tempDirPath, fileName); + final file = File(tempPath); + + await file.writeAsBytes(byteInput.bytes!); + + outputs.add(MergeInput.path(file.path)); + } else { + throw PdfCombinerException(PdfCombinerMessages.errorMessagePDF(input.url!)); + } + } catch (e) { + throw PdfCombinerException(e.toString()); + } + + } + } + return outputs; + } + /// Returns the absolute path to the system's temporary directory. /// /// By default, this returns the system's temporary directory path @@ -111,6 +169,8 @@ class DocumentUtils { case MergeInputType.bytes: return FileMagicNumber.detectFileTypeFromBytes(input.bytes!) == FileMagicNumberType.pdf; + case MergeInputType.url: + return input.url.stringToMagicType == FileMagicNumberType.pdf; } } @@ -151,6 +211,9 @@ class DocumentUtils { case MergeInputType.bytes: fileType = FileMagicNumber.detectFileTypeFromBytes(input.bytes!); break; + case MergeInputType.url: + fileType = input.url!.stringToMagicType; + break; } return fileType == FileMagicNumberType.png || fileType == FileMagicNumberType.jpg || @@ -177,11 +240,13 @@ class DocumentUtils { await tempDir.create(recursive: true); } final fileName = - '${input.type.filenamePrefix()}_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}${input.type.extension()}'; + '${input.type.filenamePrefix()}_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}${input.type.extension(input)}'; final tempPath = p.join(tempDirPath, fileName); final file = File(tempPath); await file.writeAsBytes(input.bytes!); return tempPath; + case MergeInputType.url: + return input.url!; } } } diff --git a/lib/utils/document_utils_web.dart b/lib/utils/document_utils_web.dart index b49fbdc9..1f5fed28 100644 --- a/lib/utils/document_utils_web.dart +++ b/lib/utils/document_utils_web.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:file_magic_number/file_magic_number.dart'; import 'package:path/path.dart' as p; import 'package:pdf_combiner/models/merge_input.dart'; +import 'package:pdf_combiner/utils/string_extension.dart'; import 'package:web/web.dart' as web; /// Utility class for handling document-related checks in a web environment. @@ -45,6 +46,8 @@ class DocumentUtils { case MergeInputType.bytes: return FileMagicNumber.detectFileTypeFromBytes(input.bytes!) == FileMagicNumberType.pdf; + case MergeInputType.url: + return input.url.stringToMagicType == FileMagicNumberType.pdf; } } @@ -63,6 +66,9 @@ class DocumentUtils { case MergeInputType.bytes: fileType = FileMagicNumber.detectFileTypeFromBytes(input.bytes!); break; + case MergeInputType.url: + fileType = input.url.stringToMagicType; + break; } return fileType == FileMagicNumberType.png || fileType == FileMagicNumberType.jpg || @@ -99,12 +105,24 @@ class DocumentUtils { /// /// - [MergeInput.path]: Returns the path as-is. /// - [MergeInput.bytes]: Creates a blob URL and returns it. + /// - [MergeInput.url]: Returns the URL as-is. static Future prepareInput(MergeInput input) async { switch (input.type) { case MergeInputType.path: return input.path!; case MergeInputType.bytes: return createBlobUrl(input.bytes!); + case MergeInputType.url: + return input.url!; } } + + + + static Future> conversionUrlInputsToPaths( + List inputs) async { + + //Returns the same inputs in web environment is not necessary to convert urls to paths + return inputs; + } } diff --git a/lib/utils/string_extension.dart b/lib/utils/string_extension.dart new file mode 100644 index 00000000..c335cebe --- /dev/null +++ b/lib/utils/string_extension.dart @@ -0,0 +1,17 @@ +import 'package:file_magic_number/file_magic_number.dart'; + +extension StringExtension on String? { + FileMagicNumberType get stringToMagicType { + final s = this ?? ''; + final segments = s.split('/').where((p) => p.isNotEmpty).toList(); + final lastSegment = segments.isEmpty ? '' : segments.last; + final cleaned = lastSegment.split(RegExp(r'[?#]')).first; + final extension = cleaned.contains('.') + ? cleaned.substring(cleaned.lastIndexOf('.') + 1) + : ''; + return FileMagicNumberType.values.firstWhere( + (e) => e.name.toLowerCase() == extension.toLowerCase(), + orElse: () => FileMagicNumberType.unknown, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6fe3d469..95c74cc1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: plugin_platform_interface: ^2.0.2 web: ^1.0.0 file_magic_number: ^1.4.1 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/test/merge_input_test.dart b/test/merge_input_test.dart index 88e2eaa2..be72593c 100644 --- a/test/merge_input_test.dart +++ b/test/merge_input_test.dart @@ -11,7 +11,10 @@ void main() { expect(input.type, MergeInputType.path); expect(input.path, '/path/to/file.pdf'); expect(input.bytes, isNull); + expect(input.url, isNull); expect(input.type == MergeInputType.bytes, isFalse); + expect(input.type == MergeInputType.url, isFalse); + expect(input.type == MergeInputType.path, isTrue); }); test('bytes constructor creates MergeInput with bytes type', () { @@ -21,9 +24,25 @@ void main() { expect(input.type, MergeInputType.bytes); expect(input.bytes, bytes); expect(input.path, isNull); + expect(input.url, isNull); expect(input.type == MergeInputType.bytes, isTrue); + expect(input.type == MergeInputType.path, isFalse); + expect(input.type == MergeInputType.url, isFalse); }); + test('url constructor creates MergeInput with url type', () { + final input = MergeInput.url('Https://example.com/file.pdf'); + + expect(input.type, MergeInputType.url); + expect(input.path, isNull); + expect(input.bytes, isNull); + expect(input.url, 'Https://example.com/file.pdf'); + expect(input.type == MergeInputType.bytes, isFalse); + expect(input.type == MergeInputType.path, isFalse); + expect(input.type == MergeInputType.url, isTrue); + }); + + test('toString returns path for path type', () { final input = MergeInput.path('/path/to/file.pdf'); @@ -36,5 +55,11 @@ void main() { expect(input.toString(), bytes.toString()); }); + + test('toString returns url for url type', () { + final input = MergeInput.url('Https://example.com/file.pdf'); + + expect(input.toString(), 'Https://example.com/file.pdf'); + }); }); } diff --git a/test/string_extension_test.dart b/test/string_extension_test.dart new file mode 100644 index 00000000..497e9bc6 --- /dev/null +++ b/test/string_extension_test.dart @@ -0,0 +1,52 @@ +import 'package:file_magic_number/file_magic_number.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pdf_combiner/utils/string_extension.dart'; + +void main() { + group('StringExtension', () { + test('stringToMagicType detects PDF extension', () { + expect('document.pdf'.stringToMagicType, FileMagicNumberType.pdf); + expect('path/to/document.pdf'.stringToMagicType, FileMagicNumberType.pdf); + }); + + test('stringToMagicType detects PDF extension from URL', () { + expect('https://example.com/document.pdf'.stringToMagicType, + FileMagicNumberType.pdf); + expect('https://example.com/document.pdf?query=1'.stringToMagicType, + FileMagicNumberType.pdf); + }); + + test('stringToMagicType detects PNG extension', () { + expect('image.png'.stringToMagicType, FileMagicNumberType.png); + }); + + test('stringToMagicType detects JPG extension', () { + expect('image.jpg'.stringToMagicType, FileMagicNumberType.jpg); + expect('image.jpeg'.stringToMagicType, + FileMagicNumberType.unknown); + }); + + test('stringToMagicType detects HEIC extension', () { + expect('image.heic'.stringToMagicType, FileMagicNumberType.heic); + }); + + test('stringToMagicType handles unknown extension', () { + expect('file.txt'.stringToMagicType, FileMagicNumberType.unknown); + expect('file'.stringToMagicType, FileMagicNumberType.unknown); + }); + + test('stringToMagicType handles null', () { + String? nullString; + expect(nullString.stringToMagicType, FileMagicNumberType.unknown); + }); + + test('stringToMagicType handles empty string', () { + expect(''.stringToMagicType, FileMagicNumberType.unknown); + }); + + test('stringToMagicType handles complex URLs', () { + expect('https://example.com/doc.pdf#fragment'.stringToMagicType, + FileMagicNumberType.pdf); + }); + }); +} \ No newline at end of file