diff --git a/lib/model/ProductImage.dart b/lib/model/ProductImage.dart index 0c04913980..1cd43354ee 100644 --- a/lib/model/ProductImage.dart +++ b/lib/model/ProductImage.dart @@ -61,15 +61,105 @@ extension ImageSizeExtension on ImageSize? { ); } +/// Angle for image rotation. +enum ImageAngle { + /// Noon = no rotation + NOON, + + /// 3 o'clock + THREE_O_CLOCK, + + /// 6 o'clock + SIX_O_CLOCK, + + /// 9 o'clock + NINE_O_CLOCK, +} + +extension ImageAngleExtension on ImageAngle { + static const Map _DEGREES_CLOCKWISE = { + ImageAngle.NOON: 0, + ImageAngle.THREE_O_CLOCK: 90, + ImageAngle.SIX_O_CLOCK: 180, + ImageAngle.NINE_O_CLOCK: 270, + }; + + String get degreesClockwise => _DEGREES_CLOCKWISE[this]?.toString() ?? '0'; + + /// Returns the corresponding [ImageAngle], or null if not found. + static ImageAngle? fromInt(final int? clockwiseDegree) { + for (final MapEntry entry in _DEGREES_CLOCKWISE.entries) { + if (entry.value == clockwiseDegree) { + return entry.key; + } + } + return null; + } +} + /// The url to a specific product image. /// Categorized by content type, size and language class ProductImage { - ProductImage( - {required this.field, this.size, this.language, this.url, this.rev}); + ProductImage({ + required this.field, + this.size, + this.language, + this.url, + this.rev, + this.imgid, + this.angle, + this.coordinatesImageSize, + this.x1, + this.y1, + this.x2, + this.y2, + }); final ImageField field; final ImageSize? size; final OpenFoodFactsLanguage? language; String? url; + + /// Revision number int? rev; + + /// Uploaded image id (probably an `int`) + String? imgid; + + /// Image angle, compared to the uploaded image + ImageAngle? angle; + + /// On what size are the coordinates ([x1], ...)computed? 'full' or '400' + String? coordinatesImageSize; + + /// Crop coordinate x1, compared to the uploaded image + int? x1; + + /// Crop coordinate y1, compared to the uploaded image + int? y1; + + /// Crop coordinate x2, compared to the uploaded image + int? x2; + + /// Crop coordinate y2, compared to the uploaded image + int? y2; + + @override + String toString() => + 'ProductImage(' + '${field.value}' + + (size == null ? '' : ',size=${size.value}]') + + (language == null ? '' : ',language=${language.code}') + + (angle == null ? '' : ',angle=${angle!.degreesClockwise}') + + (url == null ? '' : ',url=$url') + + (imgid == null ? '' : ',imgid=$imgid') + + (rev == null ? '' : ',rev=$rev') + + (coordinatesImageSize == null + ? '' + : ',coordinatesImageSize=$coordinatesImageSize') + + (x1 == null ? '' : ',x1=$x1') + + (y1 == null ? '' : ',y1=$y1') + + (x2 == null ? '' : ',x2=$x2') + + (y2 == null ? '' : ',y2=$y2') + + ')'; } diff --git a/lib/openfoodfacts.dart b/lib/openfoodfacts.dart index 8fe486c052..08c29f5e1d 100644 --- a/lib/openfoodfacts.dart +++ b/lib/openfoodfacts.dart @@ -9,6 +9,7 @@ import 'package:openfoodfacts/interface/JsonObject.dart'; import 'package:openfoodfacts/model/KnowledgePanels.dart'; import 'package:openfoodfacts/model/OcrIngredientsResult.dart'; import 'package:openfoodfacts/model/OrderedNutrients.dart'; +import 'package:openfoodfacts/model/ProductImage.dart'; import 'package:openfoodfacts/model/TaxonomyAdditive.dart'; import 'package:openfoodfacts/model/TaxonomyAllergen.dart'; import 'package:openfoodfacts/model/TaxonomyCategory.dart'; @@ -18,6 +19,7 @@ import 'package:openfoodfacts/model/TaxonomyLabel.dart'; import 'package:openfoodfacts/model/TaxonomyLanguage.dart'; import 'package:openfoodfacts/utils/AbstractQueryConfiguration.dart'; import 'package:openfoodfacts/utils/CountryHelper.dart'; +import 'package:openfoodfacts/utils/ImageHelper.dart'; import 'package:openfoodfacts/utils/OcrField.dart'; import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; import 'package:openfoodfacts/utils/PnnsGroupQueryConfiguration.dart'; @@ -907,4 +909,119 @@ class OpenFoodAPIClient { final json = jsonDecode(response.body); return OrderedNutrients.fromJson(json); } + + /// Rotates a product image from an already uploaded image. + /// + /// "I want, for this [barcode], this [imageField] and this [language], + /// the image to be computed from the already uploaded image + /// referenced by [imgid], with a rotation of [angle]. + /// + /// Returns the URL to the "display" picture after the operation. + /// Returns null if KO, but would probably throw an exception instead. + static Future setProductImageAngle({ + required final String barcode, + required final ImageField imageField, + required final OpenFoodFactsLanguage language, + required final String imgid, + required final ImageAngle angle, + final QueryType? queryType, + }) async => + await _callProductImageCrop( + barcode: barcode, + imageField: imageField, + language: language, + imgid: imgid, + extraParameters: { + 'angle': angle.degreesClockwise, + }, + ); + + /// Crops a product image from an already uploaded image. + /// + /// "I want, for this [barcode], this [imageField] and this [language], + /// the image to be computed from the already uploaded image + /// referenced by [imgid], with a possible rotation of [angle] and then + /// a cropping on rectangle ([x1],[y1],[x2],[y2]), those coordinates + /// being taken from the uploaded image size. + /// + /// Returns the URL to the "display" picture after the operation. + /// Returns null if KO, but would probably throw an exception instead. + static Future setProductImageCrop({ + required final String barcode, + required final ImageField imageField, + required final OpenFoodFactsLanguage language, + required final String imgid, + required final int x1, + required final int y1, + required final int x2, + required final int y2, + final ImageAngle angle = ImageAngle.NOON, + final QueryType? queryType, + }) async => + await _callProductImageCrop( + barcode: barcode, + imageField: imageField, + language: language, + imgid: imgid, + extraParameters: { + 'x1': x1.toString(), + 'y1': y1.toString(), + 'x2': x2.toString(), + 'y2': y2.toString(), + 'angle': angle.degreesClockwise, + 'coordinates_image_size': 'full', + }, + ); + + /// Calls `cgi/product_image_crop.pl` on a [ProductImage]. + /// + /// Returns the URL to the "display" picture after the operation. + /// Returns null if KO, but would probably throw an exception instead. + static Future _callProductImageCrop({ + required final String barcode, + required final ImageField imageField, + required final OpenFoodFactsLanguage language, + required final String imgid, + required final Map extraParameters, + final QueryType? queryType, + }) async { + final String id = '${imageField.value}_${language.code}'; + final Map queryParameters = { + 'code': barcode, + 'id': id, + 'imgid': imgid, + }; + queryParameters.addAll(extraParameters); + final Uri uri = UriHelper.getUri( + path: 'cgi/product_image_crop.pl', + queryType: queryType, + queryParameters: queryParameters, + ); + + final Response response = await HttpHelper() + .doGetRequest(uri, userAgent: OpenFoodAPIConfiguration.userAgent); + if (response.statusCode != 200) { + throw Exception( + 'Bad response (${response.statusCode}): ${response.body}'); + } + final Map json = + jsonDecode(response.body) as Map; + final String status = json['status']; + if (status != 'status ok') { + throw Exception('Status not ok ($status)'); + } + final String imagefield = json['imagefield']; + if (imagefield != id) { + throw Exception( + 'Different imagefield: expected "$id", actual "$imageField"'); + } + final Map images = json['image']; + final String? filename = images['display_url']; + if (filename == null) { + return null; + } + return ImageHelper.getProductImageRootUrl(barcode, queryType: queryType) + + '/' + + filename; + } } diff --git a/lib/utils/ImageHelper.dart b/lib/utils/ImageHelper.dart index 91ac914453..dff2842c89 100644 --- a/lib/utils/ImageHelper.dart +++ b/lib/utils/ImageHelper.dart @@ -1,4 +1,5 @@ import 'package:image/image.dart'; +import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; import 'package:openfoodfacts/utils/QueryType.dart'; import '../model/ProductImage.dart'; import 'LanguageHelper.dart'; @@ -26,36 +27,60 @@ class ImageHelper { } } - /// Returns the image url - /// E.g. https://static.openfoodfacts.org/images/products/359/671/046/2858/front_fr.4.100.jpg" - static String? buildUrl(String? barcode, ProductImage image, - {QueryType queryType = QueryType.PROD}) { - if (barcode == null) { - return null; - } - String barcodeUrl = barcode; + /// Returns the product image full url, or null if [barcode] is null + /// + /// E.g. "https://static.openfoodfacts.org/images/products/359/671/046/2858/front_fr.4.100.jpg" + static String? buildUrl( + final String? barcode, + final ProductImage image, { + final QueryType? queryType, + }) => + barcode == null + ? null + : getProductImageRootUrl(barcode, queryType: queryType) + + '/' + + image.field.value + + '_' + + image.language.code + + '.' + + image.rev.toString() + + '.' + + image.size.toNumber() + + '.jpg'; + + /// Returns the product image filename + /// + /// E.g. "front_fr.4.100.jpg" + static String getProductImageFilename(final ProductImage image) => + image.field.value + + '_' + + image.language.code + + '.' + + image.rev.toString() + + '.' + + image.size.toNumber() + + '.jpg'; + + /// Returns the web folder of the product images (without trailing '/') + /// + /// E.g. "https://static.openfoodfacts.org/images/products/359/671/046/2858" + static String getProductImageRootUrl( + final String barcode, { + final QueryType? queryType, + }) { + final String barcodeUrl; if (barcode.length >= 9) { var p1 = barcode.substring(0, 3); var p2 = barcode.substring(3, 6); var p3 = barcode.substring(6, 9); var p4 = barcode.length >= 10 ? barcode.substring(9) : ''; - barcodeUrl = p1 + '/' + p2 + '/' + p3 + '/' + p4; + } else { + barcodeUrl = barcode; } - String urlHelper = barcodeUrl + - '/' + - image.field.value + - '_' + - image.language.code + - '.' + - image.rev.toString() + - '.' + - image.size.toNumber() + - '.jpg'; - - return queryType == QueryType.PROD - ? IMAGE_PROD_URL_BASE + urlHelper - : IMAGE_TEST_URL_BASE + urlHelper; + return OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD + ? IMAGE_PROD_URL_BASE + barcodeUrl + : IMAGE_TEST_URL_BASE + barcodeUrl; } } diff --git a/lib/utils/JsonHelper.dart b/lib/utils/JsonHelper.dart index b6f718530f..8240a88cdb 100644 --- a/lib/utils/JsonHelper.dart +++ b/lib/utils/JsonHelper.dart @@ -72,14 +72,23 @@ class JsonHelper { for (var field in ImageField.values) { for (OpenFoodFactsLanguage lang in OpenFoodFactsLanguage.values) { // get the field object e.g. front_en - String fieldName = field.value + '_' + lang.code; + final String fieldName = field.value + '_' + lang.code; if (json[fieldName] == null) continue; - var fieldObject = json[fieldName] as Map?; + final fieldObject = json[fieldName] as Map?; if (fieldObject == null) continue; - // get the rev object - var rev = JsonObject.parseInt(fieldObject['rev']); + final rev = JsonObject.parseInt(fieldObject['rev']); + final String imgid = fieldObject['imgid'].toString(); + final ImageAngle? angle = ImageAngleExtension.fromInt( + JsonObject.parseInt(fieldObject['angle']), + ); + final String? coordinatesImageSize = + fieldObject['coordinates_image_size']; + final int? x1 = JsonObject.parseInt(fieldObject['x1']); + final int? y1 = JsonObject.parseInt(fieldObject['y1']); + final int? x2 = JsonObject.parseInt(fieldObject['x2']); + final int? y2 = JsonObject.parseInt(fieldObject['y2']); // get the sizes object var sizesObject = fieldObject['sizes'] as Map?; @@ -91,8 +100,19 @@ class JsonHelper { var numberObject = sizesObject[number] as Map?; if (numberObject == null) continue; - var image = - ProductImage(field: field, size: size, language: lang, rev: rev); + var image = ProductImage( + field: field, + size: size, + language: lang, + rev: rev, + imgid: imgid, + angle: angle, + coordinatesImageSize: coordinatesImageSize, + x1: x1, + y1: y1, + x2: x2, + y2: y2, + ); imageList.add(image); } } diff --git a/lib/utils/ProductHelper.dart b/lib/utils/ProductHelper.dart index bb9083a359..6b3a82398c 100644 --- a/lib/utils/ProductHelper.dart +++ b/lib/utils/ProductHelper.dart @@ -1,6 +1,5 @@ import 'package:openfoodfacts/utils/ImageHelper.dart'; import 'package:openfoodfacts/utils/LanguageHelper.dart'; -import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; import 'package:openfoodfacts/utils/QueryType.dart'; import '../model/Product.dart'; @@ -36,7 +35,7 @@ class ProductHelper { image.url = ImageHelper.buildUrl( product.barcode, image, - queryType: OpenFoodAPIConfiguration.getQueryType(queryType), + queryType: queryType, ); } } diff --git a/lib/utils/UriHelper.dart b/lib/utils/UriHelper.dart index da3f58bad6..cdea0ec39a 100644 --- a/lib/utils/UriHelper.dart +++ b/lib/utils/UriHelper.dart @@ -10,35 +10,33 @@ class UriHelper { ///Returns a OFF uri with the in the [OpenFoodAPIConfiguration] specified settings static Uri getUri({ - String? path, - Map? queryParameters, - QueryType? queryType, - }) { - return Uri( - scheme: OpenFoodAPIConfiguration.uriScheme, - host: OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD - ? OpenFoodAPIConfiguration.uriProdHost - : OpenFoodAPIConfiguration.uriTestHost, - path: path, - queryParameters: queryParameters, - ); - } + required final String path, + final Map? queryParameters, + final QueryType? queryType, + }) => + Uri( + scheme: OpenFoodAPIConfiguration.uriScheme, + host: OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD + ? OpenFoodAPIConfiguration.uriProdHost + : OpenFoodAPIConfiguration.uriTestHost, + path: path, + queryParameters: queryParameters, + ); ///Returns a OFF-Robotoff uri with the in the [OpenFoodAPIConfiguration] specified settings static Uri getRobotoffUri({ - String? path, - Map? queryParameters, - QueryType? queryType, - }) { - return Uri( - scheme: OpenFoodAPIConfiguration.uriScheme, - host: OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD - ? OpenFoodAPIConfiguration.uriProdHostRobotoff - : OpenFoodAPIConfiguration.uriTestHostRobotoff, - path: path, - queryParameters: queryParameters, - ); - } + required final String path, + final Map? queryParameters, + final QueryType? queryType, + }) => + Uri( + scheme: OpenFoodAPIConfiguration.uriScheme, + host: OpenFoodAPIConfiguration.getQueryType(queryType) == QueryType.PROD + ? OpenFoodAPIConfiguration.uriProdHostRobotoff + : OpenFoodAPIConfiguration.uriTestHostRobotoff, + path: path, + queryParameters: queryParameters, + ); /// Replaces the subdomain of an URI with specific country and language /// diff --git a/test/api_addProductImage_test.dart b/test/api_addProductImage_test.dart index 1c277c6f2e..d28fb5d02f 100644 --- a/test/api_addProductImage_test.dart +++ b/test/api_addProductImage_test.dart @@ -1,21 +1,88 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; import 'package:openfoodfacts/utils/QueryType.dart'; +import 'package:openfoodfacts/utils/UriReader.dart'; import 'package:test/test.dart'; import 'test_constants.dart'; void main() { OpenFoodAPIConfiguration.globalQueryType = QueryType.TEST; + /// Common constants for several image operations + const String barcode = '4250752200784'; + const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.GERMAN; + const ImageField imageField = ImageField.FRONT; + + /// Returns the width and height (pixels) and size (bytes) of JPEG data + /// + /// Returns an empty List if an error occurred. + /// * index 0: image width in pixels + /// * index 1: image height in pixels + /// * index 2: file size in bytes + /// Inspiration found in https://github.com/CaiJingLong/dart_image_size_getter + List _getJpegSize(final List data) { + int start = 2; + while (true) { + if (data[start] != 0xFF) { + // not supposed to happen + break; + } + final int type = data[start + 1]; + if (type == 0xC0 || type == 0xC2) { + final int width = 256 * data[start + 7] + data[start + 8]; + final int height = 256 * data[start + 5] + data[start + 6]; + return [width, height, data.length]; + } else { + final int blockLength = 2 + data[start + 2] * 256 + data[start + 3]; + start += blockLength; + } + } + // not supposed to happen + return []; + } + + /// Returns the width and height (pixels) and size (bytes) of a JPEG URL file + Future> _getJpegUrlSize(final String url) async => _getJpegSize( + await UriReader.instance!.readAsBytes(Uri.parse(url)), + ); + + /// Returns the imgid, i.e. the unique id for (uploaded image x product) + /// + /// That imgid has only sense for this [barcode], and references the image + /// currently used as a base for this [imageField] and this [language]. + Future _getImgid( + final String barcode, + final ImageField imageField, + final OpenFoodFactsLanguage language, + ) async { + final ProductQueryConfiguration configurations = ProductQueryConfiguration( + barcode, + fields: [ProductField.IMAGES], + ); + final ProductResult result = + await OpenFoodAPIClient.getProduct(configurations); + expect(result.status, isNotNull); + expect(result.product!.images, isNotEmpty); + + for (final ProductImage productImage in result.product!.images!) { + if (productImage.field == imageField || + productImage.language == language) { + return productImage.imgid; + } + } + return null; + } + group('$OpenFoodAPIClient add product images', () { + // TODO(monsieurtanuki): test with big pic may crash (e.g. 4000x3000, 2.5Mb) test('add front image test', () async { - SendImage image = SendImage( - lang: OpenFoodFactsLanguage.GERMAN, - barcode: '4250752200784', - imageField: ImageField.FRONT, + final SendImage image = SendImage( + lang: language, + barcode: barcode, + imageField: imageField, imageUri: Uri.file('test/test_assets/front_de.jpg'), ); - Status status = await OpenFoodAPIClient.addProductImage( + final Status status = await OpenFoodAPIClient.addProductImage( TestConstants.TEST_USER, image, ); @@ -24,7 +91,7 @@ void main() { expect(status.error, 'Dieses Foto wurde schon hochgeladen.'); }); - test('add ingredients image test 1', () async { + test('add ingredients image test', () async { SendImage image = SendImage( lang: OpenFoodFactsLanguage.ENGLISH, barcode: '0048151623426', @@ -40,32 +107,98 @@ void main() { expect(status.error, 'This picture has already been sent.'); }); - // TODO(monsieurtanuki): test with a bigger pic used to crash (4000x3000, 2.5Mb) - test('add ingredients image test 2', () async { - SendImage image = SendImage( - lang: OpenFoodFactsLanguage.DANISH, - barcode: '5722970900207', - imageField: ImageField.FRONT, - imageUri: Uri.file('test/test_assets/corn_da.jpg'), - ); - Status status = await OpenFoodAPIClient.addProductImage( - TestConstants.TEST_USER, - image, - ); - - assert(status.error != 'field imgupload_front_xx not set'); - }); - - test('Read image from PROD', () async { + test('read image', () async { //Get product without setting ProductField - ProductQueryConfiguration configurations = + final ProductQueryConfiguration configurations = ProductQueryConfiguration('7622210449283'); - ProductResult result = await OpenFoodAPIClient.getProduct( + final ProductResult result = await OpenFoodAPIClient.getProduct( configurations, user: TestConstants.TEST_USER, ); - expect(result.status != null, true); - assert(result.product!.images!.isNotEmpty); + expect(result.status, isNotNull); + expect(result.product!.images, isNotEmpty); }); }); + + group('$OpenFoodAPIClient modify product image', () { + test('image angle', () async { + const Set tiltedAngles = { + ImageAngle.NINE_O_CLOCK, + ImageAngle.THREE_O_CLOCK, + }; + + final String? imgid = await _getImgid(barcode, imageField, language); + expect(imgid, isNotNull); + + final String productImageRootUrl = + ImageHelper.getProductImageRootUrl(barcode); + final String uploadedImageUrl = '$productImageRootUrl/$imgid.jpg'; + final List uploadedSize = await _getJpegUrlSize(uploadedImageUrl); + final int uploadedWidth = uploadedSize[0]; + final int uploadedHeight = uploadedSize[1]; + + for (final ImageAngle angle in ImageAngle.values) { + final String? newUrl = await OpenFoodAPIClient.setProductImageAngle( + barcode: barcode, + imageField: imageField, + language: language, + imgid: imgid!, + angle: angle, + ); + expect(newUrl, isNotNull); + + final List newSize = await _getJpegUrlSize(newUrl!); + final int newWidth = newSize[0]; + final int newHeight = newSize[1]; + + final bool tilted = tiltedAngles.contains(angle); + final int fullExpectedWidth = tilted ? uploadedHeight : uploadedWidth; + final int fullExpectedHeight = tilted ? uploadedWidth : uploadedHeight; + + // checking the aspect ratio, using multiplication instead of division + final int check1 = newWidth * fullExpectedHeight; + final int check2 = newHeight * fullExpectedWidth; + expect(check1, check2); + } + }, + timeout: Timeout( + // this guy is rather slow + Duration(seconds: 90), + )); + + test('image crop', () async { + const int width = 50; + const int height = 300; + const int x1 = 10; + const int y1 = 20; + + final String? imgid = await _getImgid(barcode, imageField, language); + expect(imgid, isNotNull); + + for (final ImageAngle angle in ImageAngle.values) { + final String? newUrl = await OpenFoodAPIClient.setProductImageCrop( + barcode: barcode, + imageField: imageField, + language: language, + imgid: imgid!, + angle: angle, + x1: x1, + y1: y1, + x2: x1 + width, + y2: y1 + height, + ); + expect(newUrl, isNotNull); + + final List newSize = await _getJpegUrlSize(newUrl!); + final int newWidth = newSize[0]; + final int newHeight = newSize[1]; + expect(newWidth, width); + expect(newHeight, height); + } + }, + timeout: Timeout( + // this guy is rather slow + Duration(seconds: 90), + )); + }); } diff --git a/test/test_assets/corn_da.jpg b/test/test_assets/corn_da.jpg deleted file mode 100644 index 97506a784b..0000000000 Binary files a/test/test_assets/corn_da.jpg and /dev/null differ