18.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:06:50 +02:00
parent d72e748793
commit 0a7ae8db93
337 changed files with 399651 additions and 232598 deletions

View file

@ -3,6 +3,7 @@
import base64
import binascii
import io
from typing import Tuple, Union
from PIL import Image, ImageOps
# We can preload Ico too because it is considered safe
@ -16,9 +17,12 @@ from random import randrange
from odoo.exceptions import UserError
from odoo.tools.misc import DotDict
from odoo.tools.translate import _
from odoo.tools.translate import LazyTranslate
__all__ = ["image_process"]
_lt = LazyTranslate('base')
# Preload PIL with the minimal subset of image formats we need
Image.preinit()
Image._initialized = 2
@ -53,16 +57,15 @@ EXIF_TAG_ORIENTATION_TO_TRANSPOSE_METHODS = { # Initial side on 1st row/col:
IMAGE_MAX_RESOLUTION = 50e6
class ImageProcess():
class ImageProcess:
def __init__(self, source, verify_resolution=True):
"""Initialize the `source` image for processing.
"""Initialize the ``source`` image for processing.
:param source: the original image binary
:param bytes source: the original image binary
No processing will be done if the `source` is falsy or if
the image is SVG.
:param verify_resolution: if True, make sure the original image size is not
excessive before starting to process it. The max allowed resolution is
defined by `IMAGE_MAX_RESOLUTION`.
@ -82,7 +85,7 @@ class ImageProcess():
try:
self.image = Image.open(io.BytesIO(source))
except (OSError, binascii.Error):
raise UserError(_("This file could not be decoded as an image file."))
raise UserError(_lt("This file could not be decoded as an image file."))
# Original format has to be saved before fixing the orientation or
# doing any other operations because the information will be lost on
@ -93,18 +96,15 @@ class ImageProcess():
w, h = self.image.size
if verify_resolution and w * h > IMAGE_MAX_RESOLUTION:
raise UserError(_("Too large image (above %sMpx), reduce the image size.", str(IMAGE_MAX_RESOLUTION / 1e6)))
raise UserError(_lt("Too large image (above %sMpx), reduce the image size.", str(IMAGE_MAX_RESOLUTION / 1e6)))
def image_quality(self, quality=0, output_format=''):
"""Return the image resulting of all the image processing
operations that have been applied previously.
Return False if the initialized `image` was falsy, and return
the initialized `image` without change if it was SVG.
Also return the initialized `image` if no operations have been applied
and the `output_format` is the same as the original format and the
quality is not specified.
The source is returned as-is if it's an SVG, or if no operations have
been applied, the `output_format` is the same as the original format,
and the quality is not specified.
:param int quality: quality setting to apply. Default to 0.
@ -113,11 +113,12 @@ class ImageProcess():
was changed, otherwise the original image is returned.
- for PNG: set falsy to prevent conversion to a WEB palette.
- for other formats: no effect.
:param str output_format: the output format. Can be PNG, JPEG, GIF, or ICO.
Default to the format of the original image. BMP is converted to
PNG, other formats than those mentioned above are converted to JPEG.
:return: image
:rtype: bytes or False
:param str output_format: Can be PNG, JPEG, GIF, or ICO.
Default to the format of the original image if a valid output format,
otherwise BMP is converted to PNG and the rest are converted to JPEG.
:return: the final image, or ``False`` if the original ``source`` was falsy.
:rtype: bytes | False
"""
if not self.image:
return self.source
@ -158,11 +159,12 @@ class ImageProcess():
return self.source
return output_bytes
def resize(self, max_width=0, max_height=0):
def resize(self, max_width=0, max_height=0, expand=False):
"""Resize the image.
The image is never resized above the current image size. This method is
only to create a smaller version of the image.
The image is not resized above the current image size, unless the expand
parameter is True. This method is used by default to create smaller versions
of the image.
The current ratio is preserved. To change the ratio, see `crop_resize`.
@ -174,6 +176,7 @@ class ImageProcess():
:param int max_width: max width
:param int max_height: max height
:param bool expand: whether or not the image size can be increased
:return: self to allow chaining
:rtype: ImageProcess
"""
@ -181,6 +184,10 @@ class ImageProcess():
w, h = self.image.size
asked_width = max_width or (w * max_height) // h
asked_height = max_height or (h * max_width) // w
if expand and (asked_width > w or asked_height > h):
self.image = self.image.resize((asked_width, asked_height))
self.operationsCount += 1
return self
if asked_width != w or asked_height != h:
self.image.thumbnail((asked_width, asked_height), Resampling.LANCZOS)
if self.image.width != w or self.image.height != h:
@ -246,27 +253,43 @@ class ImageProcess():
return self.resize(max_width, max_height)
def colorize(self):
"""Replace the transparent background by a random color.
def colorize(self, color=None):
"""Replace the transparent background by a given color, or by a random one.
:param tuple color: RGB values for the color to use
:return: self to allow chaining
:rtype: ImageProcess
"""
if color is None:
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
if self.image:
original = self.image
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
self.image = Image.new('RGB', original.size)
self.image.paste(color, box=(0, 0) + original.size)
self.image.paste(original, mask=original)
self.operationsCount += 1
return self
def add_padding(self, padding):
"""Expand the image size by adding padding around the image
def image_process(source, size=(0, 0), verify_resolution=False, quality=0, crop=None, colorize=False, output_format=''):
:param int padding: thickness of the padding
:return: self to allow chaining
:rtype: ImageProcess
"""
if self.image:
img_width, img_height = self.image.size
self.image = self.image.resize((img_width - 2 * padding, img_height - 2 * padding))
self.image = ImageOps.expand(self.image, border=padding)
self.operationsCount += 1
return self
def image_process(source, size=(0, 0), verify_resolution=False, quality=0, expand=False, crop=None, colorize=False, output_format='', padding=False):
"""Process the `source` image by executing the given operations and
return the result image.
"""
if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format):
if not source or ((not size or (not size[0] and not size[1])) and not verify_resolution and not quality and not crop and not colorize and not output_format and not padding):
# for performance: don't do anything if the image is falsy or if
# no operations have been requested
return source
@ -282,9 +305,11 @@ def image_process(source, size=(0, 0), verify_resolution=False, quality=0, crop=
center_y = 1
image.crop_resize(max_width=size[0], max_height=size[1], center_x=center_x, center_y=center_y)
else:
image.resize(max_width=size[0], max_height=size[1])
image.resize(max_width=size[0], max_height=size[1], expand=expand)
if padding:
image.add_padding(padding)
if colorize:
image.colorize()
image.colorize(colorize if isinstance(colorize, tuple) else None)
return image.image_quality(quality=quality, output_format=output_format)
@ -397,30 +422,28 @@ def binary_to_image(source):
try:
return Image.open(io.BytesIO(source))
except (OSError, binascii.Error):
raise UserError(_("This file could not be decoded as an image file."))
raise UserError(_lt("This file could not be decoded as an image file."))
def base64_to_image(base64_source):
def base64_to_image(base64_source: Union[str, bytes]) -> Image:
"""Return a PIL image from the given `base64_source`.
:param base64_source: the image base64 encoded
:type base64_source: string or bytes
:rtype: ~PIL.Image.Image
:raise: UserError if the base64 is incorrect or the image can't be identified by PIL
"""
try:
return Image.open(io.BytesIO(base64.b64decode(base64_source)))
except (OSError, binascii.Error):
raise UserError(_("This file could not be decoded as an image file."))
raise UserError(_lt("This file could not be decoded as an image file."))
def image_apply_opt(image, output_format, **params):
"""Return the given PIL `image` using `params`.
def image_apply_opt(image: Image, output_format: str, **params) -> bytes:
"""Return the serialization of the provided `image` to `output_format`
using `params`.
:type image: ~PIL.Image.Image
:param str output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter
:param image: the image to encode
:param output_format: :meth:`~PIL.Image.Image.save`'s ``format`` parameter
:param dict params: params to expand when calling :meth:`~PIL.Image.Image.save`
:return: the image formatted
:rtype: bytes
"""
if output_format == 'JPEG' and image.mode not in ['1', 'L', 'RGB']:
image = image.convert("RGB")
@ -452,7 +475,7 @@ def get_webp_size(source):
:return: (width, height) tuple, or None if not supported
"""
if not (source[0:4] == b'RIFF' and source[8:15] == b'WEBPVP8'):
raise UserError(_("This file is not a webp file."))
raise UserError(_lt("This file is not a webp file."))
vp8_type = source[15]
if vp8_type == 0x20: # 0x20 = ' '
@ -506,23 +529,22 @@ def is_image_size_above(base64_source_1, base64_source_2):
return image_source.width > image_target.width or image_source.height > image_target.height
def image_guess_size_from_field_name(field_name):
def image_guess_size_from_field_name(field_name: str) -> Tuple[int, int]:
"""Attempt to guess the image size based on `field_name`.
If it can't be guessed or if it is a custom field: return (0, 0) instead.
:param str field_name: the name of a field
:param field_name: the name of a field
:return: the guessed size
:rtype: tuple (width, height)
"""
if field_name == 'image':
return (1024, 1024)
if field_name.startswith('x_'):
return (0, 0)
try:
suffix = int(field_name.split('_')[-1])
suffix = int(field_name.rsplit('_', 1)[-1])
except ValueError:
return (0, 0)
return 0, 0
if suffix < 16:
# If the suffix is less than 16, it's probably not the size
@ -531,7 +553,7 @@ def image_guess_size_from_field_name(field_name):
return (suffix, suffix)
def image_data_uri(base64_source):
def image_data_uri(base64_source: bytes) -> str:
"""This returns data URL scheme according RFC 2397
(https://tools.ietf.org/html/rfc2397) for all kind of supported images
(PNG, GIF, JPG and SVG), defaulting on PNG type if not mimetype detected.