diff --git a/odoo/addons/base/tests/test_image.py b/odoo/addons/base/tests/test_image.py index 838c37a84a1f9..2d4c3940b8dc3 100644 --- a/odoo/addons/base/tests/test_image.py +++ b/odoo/addons/base/tests/test_image.py @@ -3,6 +3,7 @@ import base64 import binascii + from PIL import Image, ImageDraw, PngImagePlugin from odoo import tools @@ -67,6 +68,29 @@ def test_01_image_to_base64(self): image_base64 = tools.image_to_base64(image, 'PNG') self.assertEqual(image_base64, self.base64_1x1_png) + def test_02_image_fix_orientation(self): + """Test that the orientation of images is correct.""" + + # Colors that can be distinguished among themselves even with jpeg loss. + blue = (0, 0, 255) + yellow = (255, 255, 0) + green = (0, 255, 0) + pink = (255, 0, 255) + # Image large enough so jpeg loss is not a huge factor in the corners. + size = 50 + expected = (blue, yellow, green, pink) + + # They are all supposed to be same image: (blue, yellow, green, pink) in + # that order, but each encoded with a different orientation. + self._orientation_test(1, (blue, yellow, green, pink), size, expected) # top/left + self._orientation_test(2, (yellow, blue, pink, green), size, expected) # top/right + self._orientation_test(3, (pink, green, yellow, blue), size, expected) # bottom/right + self._orientation_test(4, (green, pink, blue, yellow), size, expected) # bottom/left + self._orientation_test(5, (blue, green, yellow, pink), size, expected) # left/top + self._orientation_test(6, (yellow, pink, blue, green), size, expected) # right/top + self._orientation_test(7, (pink, yellow, green, blue), size, expected) # right/bottom + self._orientation_test(8, (green, blue, pink, yellow), size, expected) # left/bottom + def test_10_image_process_base64_source(self): """Test the base64_source parameter of image_process.""" wrong_base64 = b'oazdazpodazdpok' @@ -236,3 +260,33 @@ def test_16_image_process_format(self): def test_20_image_data_uri(self): """Test that image_data_uri is working as expected.""" self.assertEqual(tools.image_data_uri(self.base64_1x1_png), 'data:image/png;base64,' + self.base64_1x1_png.decode('ascii')) + + def _assertAlmostEqualSequence(self, rgb1, rgb2, delta=10): + self.assertEqual(len(rgb1), len(rgb2)) + for index, t in enumerate(zip(rgb1, rgb2)): + self.assertAlmostEqual(t[0], t[1], delta=delta, msg="%s vs %s at %d" % (rgb1, rgb2, index)) + + def _get_exif_colored_square_b64(self, orientation, colors, size): + image = Image.new('RGB', (size, size), color=self.bg_color) + draw = ImageDraw.Draw(image) + # Paint the colors on the 4 corners, to be able to test which colors + # move on which corners. + draw.rectangle(xy=[(0, 0), (size // 2, size // 2)], fill=colors[0]) # top/left + draw.rectangle(xy=[(size // 2, 0), (size, size // 2)], fill=colors[1]) # top/right + draw.rectangle(xy=[(0, size // 2), (size // 2, size)], fill=colors[2]) # bottom/left + draw.rectangle(xy=[(size // 2, size // 2), (size, size)], fill=colors[3]) # bottom/right + # Set the proper exif tag based on orientation params. + exif = b'Exif\x00\x00II*\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00' + bytes([orientation]) + b'\x00\x00\x00\x00\x00\x00\x00' + # The image image is saved with the exif tag. + return tools.image_to_base64(image, 'JPEG', exif=exif) + + def _orientation_test(self, orientation, colors, size, expected): + # Generate the test image based on orientation and order of colors. + b64_image = self._get_exif_colored_square_b64(orientation, colors, size) + # The image is read again now that it has orientation added. + fixed_image = tools.image_fix_orientation(tools.base64_to_image(b64_image)) + # Ensure colors are in the right order (blue, yellow, green, pink). + self._assertAlmostEqualSequence(fixed_image.getpixel((0, 0)), expected[0]) # top/left + self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, 0)), expected[1]) # top/right + self._assertAlmostEqualSequence(fixed_image.getpixel((0, size - 1)), expected[2]) # bottom/left + self._assertAlmostEqualSequence(fixed_image.getpixel((size - 1, size - 1)), expected[3]) # bottom/right diff --git a/odoo/addons/base/wizard/base_document_layout.py b/odoo/addons/base/wizard/base_document_layout.py index e41ed375d90db..d380c1c49a6d5 100644 --- a/odoo/addons/base/wizard/base_document_layout.py +++ b/odoo/addons/base/wizard/base_document_layout.py @@ -137,7 +137,7 @@ def _parse_logo_colors(self, logo=None, white_threshold=225): logo += b'===' if type(logo) == bytes else '===' try: # Catches exceptions caused by logo not being an image - image = tools.base64_to_image(logo) + image = tools.image_fix_orientation(tools.base64_to_image(logo)) except Exception: return False, False diff --git a/odoo/tools/image.py b/odoo/tools/image.py index f5b4b953da90b..590bdf1bbfa8f 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -4,7 +4,7 @@ import binascii import io -from PIL import Image +from PIL import Image, ImageOps # We can preload Ico too because it is considered safe from PIL import IcoImagePlugin @@ -27,6 +27,21 @@ b'P': 'svg+xml', } +EXIF_TAG_ORIENTATION = 0x112 +# The target is to have 1st row/col to be top/left +# Note: rotate is counterclockwise +EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col: + 0: [], # reserved + 1: [], # top/left + 2: [Image.FLIP_LEFT_RIGHT], # top/right + 3: [Image.ROTATE_180], # bottom/right + 4: [Image.FLIP_TOP_BOTTOM], # bottom/left + 5: [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90], # left/top + 6: [Image.ROTATE_270], # right/top + 7: [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90], # right/bottom + 8: [Image.ROTATE_90], # left/bottom +} + # Arbitraty limit to fit most resolutions, including Nokia Lumia 1020 photo, # 8K with a ratio up to 16:10, and almost all variants of 4320p IMAGE_MAX_RESOLUTION = 45e6 @@ -62,12 +77,17 @@ def __init__(self, base64_source, verify_resolution=True): else: self.image = base64_to_image(self.base64_source) + # Original format has to be saved before fixing the orientation or + # doing any other operations because the information will be lost on + # the resulting image. + self.original_format = (self.image.format or '').upper() + + self.image = image_fix_orientation(self.image) + w, h = self.image.size if verify_resolution and w * h > IMAGE_MAX_RESOLUTION: raise ValueError(_("Image size excessive, uploaded images must be smaller than %s million pixels.") % str(IMAGE_MAX_RESOLUTION / 10e6)) - self.original_format = self.image.format.upper() - def image_base64(self, quality=0, output_format=''): """Return the base64 encoded image resulting of all the image processing operations that have been applied previously. @@ -152,7 +172,7 @@ def resize(self, max_width=0, max_height=0): :return: self to allow chaining :rtype: ImageProcess """ - if self.image and self.image.format != 'GIF' and (max_width or max_height): + if self.image and self.original_format != 'GIF' and (max_width or max_height): w, h = self.image.size asked_width = max_width or (w * max_height) // h asked_height = max_height or (h * max_width) // w @@ -198,7 +218,7 @@ def crop_resize(self, max_width, max_height, center_x=0.5, center_y=0.5): :return: self to allow chaining :rtype: ImageProcess """ - if self.image and self.image.format != 'GIF' and max_width and max_height: + if self.image and self.original_format != 'GIF' and max_width and max_height: w, h = self.image.size # We want to keep as much of the image as possible -> at least one # of the 2 crop dimensions always has to be the same value as the @@ -331,6 +351,45 @@ def average_dominant_color(colors, mitigate=175, max_margin=140): return tuple(final_dominant), remaining +def image_fix_orientation(image): + """Fix the orientation of the image if it has an EXIF orientation tag. + + This typically happens for images taken from a non-standard orientation + by some phones or other devices that are able to report orientation. + + The specified transposition is applied to the image before all other + operations, because all of them expect the image to be in its final + orientation, which is the case only when the first row of pixels is the top + of the image and the first column of pixels is the left of the image. + + Moreover the EXIF tags will not be kept when the image is later saved, so + the transposition has to be done to ensure the final image is correctly + orientated. + + Note: to be completely correct, the resulting image should have its exif + orientation tag removed, since the transpositions have been applied. + However since this tag is not used in the code, it is acceptable to + save the complexity of removing it. + + :param image: the source image + :type image: PIL.Image + + :return: the resulting image, copy of the source, with orientation fixed + or the source image if no operation was applied + :rtype: PIL.Image + """ + # `exif_transpose` was added in Pillow 6.0 + if hasattr(ImageOps, 'exif_transpose'): + return ImageOps.exif_transpose(image) + if (image.format or '').upper() == 'JPEG' and hasattr(image, '_getexif'): + exif = image._getexif() + if exif: + orientation = exif.get(EXIF_TAG_ORIENTATION, 0) + for method in EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS.get(orientation, []): + image = image.transpose(method) + return image + + def base64_to_image(base64_source): """Return a PIL image from the given `base64_source`. @@ -374,8 +433,8 @@ def is_image_size_above(base64_source_1, base64_source_2): if base64_source_1[:1] in (b'P', 'P') or base64_source_2[:1] in (b'P', 'P'): # False for SVG return False - image_source = base64_to_image(base64_source_1) - image_target = base64_to_image(base64_source_2) + image_source = image_fix_orientation(base64_to_image(base64_source_1)) + image_target = image_fix_orientation(base64_to_image(base64_source_2)) return image_source.width > image_target.width or image_source.height > image_target.height