auto compress product icons

This commit is contained in:
thomas girod 2024-09-14 21:51:35 +02:00
parent e2b42145e1
commit 79ef151ad3
6 changed files with 191 additions and 12 deletions

125
core/fields.py Normal file
View File

@ -0,0 +1,125 @@
from pathlib import Path
from django.core.exceptions import FieldError
from django.db import models
from django.db.models.fields.files import ImageFieldFile
from PIL import Image
from core.utils import resize_image_explicit
class ResizedImageFieldFile(ImageFieldFile):
def get_resized_dimensions(self, image: Image.Image) -> tuple[int, int]:
"""Get the dimensions of the resized image.
If the width and height are given, they are used.
If only one is given, the other is calculated to keep the same ratio.
Returns:
Tuple of width and height
"""
width = self.field.width
height = self.field.height
if width is not None and height is not None:
return self.field.width, self.field.height
if width is None:
width = int(image.width * height / image.height)
elif height is None:
height = int(image.height * width / image.width)
return width, height
def get_name(self) -> str:
"""Get the name of the resized image.
If the field has a force_format attribute,
the extension of the file will be changed to match it.
Otherwise, the name is left unchanged.
Raises:
ValueError: If the image format is unknown
"""
if not self.field.force_format:
return self.name
formats = {val: key for key, val in Image.registered_extensions().items()}
new_format = self.field.force_format
if new_format in formats:
extension = formats[new_format]
else:
raise ValueError(f"Unknown format {new_format}")
return str(Path(self.file.name).with_suffix(f".{extension}"))
def save(self, name, content, save=True): # noqa FBT002
content.file.seek(0)
img = Image.open(content.file)
width, height = self.get_resized_dimensions(img)
img_format = self.field.force_format or img.format
new_content = resize_image_explicit(img, (width, height), img_format)
name = self.get_name()
return super().save(name, new_content, save)
class ResizedImageField(models.ImageField):
"""A field that automatically resizes images to a given size.
This field is useful for profile pictures or product icons, for example.
The final size of the image is determined by the width and height parameters :
- If both are given, the image will be resized
to fit in a rectangle of width x height
- If only one is given, the other will be calculated to keep the same ratio
If the force_format parameter is given, the image will be converted to this format.
Examples:
To resize an image with a height of 100px, without changing the ratio,
and a format of WEBP :
```python
class Product(models.Model):
icon = ResizedImageField(height=100, force_format="WEBP")
```
To explicitly resize an image to 100x100px (but possibly change the ratio) :
```python
class Product(models.Model):
icon = ResizedImageField(width=100, height=100)
```
Raises:
FieldError: If neither width nor height is given
Args:
width: If given, the width of the resized image
height: If given, the height of the resized image
force_format: If given, the image will be converted to this format
"""
attr_class = ResizedImageFieldFile
def __init__(
self,
width: int | None = None,
height: int | None = None,
force_format: str | None = None,
**kwargs,
):
if width is None and height is None:
raise FieldError(
f"{self.__class__.__name__} requires "
"width, height or both, but got neither"
)
self.width = width
self.height = height
self.force_format = force_format
super().__init__(**kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.width is not None:
kwargs["width"] = self.width
if self.height is not None:
kwargs["height"] = self.height
kwargs["force_format"] = self.force_format
return name, path, args, kwargs

View File

@ -25,7 +25,7 @@ from django.core.files.base import ContentFile
from django.http import HttpRequest
from django.utils import timezone
from PIL import ExifTags
from PIL.Image import Resampling
from PIL.Image import Image, Resampling
def get_start_of_semester(today: Optional[date] = None) -> date:
@ -82,18 +82,20 @@ def get_semester_code(d: Optional[date] = None) -> str:
return "P" + str(start.year)[-2:]
def scale_dimension(width, height, long_edge):
ratio = long_edge / max(width, height)
return int(width * ratio), int(height * ratio)
def resize_image(im, edge, img_format):
def resize_image(im: Image, edge: int, img_format: str):
"""Resize an image to fit the given edge length and format."""
(w, h) = im.size
(width, height) = scale_dimension(w, h, long_edge=edge)
ratio = edge / max(w, h)
(width, height) = int(w * ratio), int(h * ratio)
return resize_image_explicit(im, (width, height), img_format)
def resize_image_explicit(im: Image, size: tuple[int, int], img_format: str):
"""Resize an image to the given size and format."""
img_format = img_format.upper()
content = BytesIO()
# use the lanczos filter for antialiasing and discard the alpha channel
im = im.resize((width, height), Resampling.LANCZOS)
im = im.resize((size[0], size[1]), Resampling.LANCZOS)
if img_format == "JPEG":
# converting an image with an alpha channel to jpeg would cause a crash
im = im.convert("RGB")

View File

@ -0,0 +1,37 @@
# Generated by Django 4.2.16 on 2024-09-14 18:02
from django.db import migrations
import core.fields
class Migration(migrations.Migration):
dependencies = [
("counter", "0021_rename_check_cashregistersummaryitem_is_checked"),
]
operations = [
migrations.AlterField(
model_name="product",
name="icon",
field=core.fields.ResizedImageField(
blank=True,
height=70,
force_format="WEBP",
null=True,
upload_to="products",
verbose_name="icon",
),
),
migrations.AlterField(
model_name="producttype",
name="icon",
field=core.fields.ResizedImageField(
blank=True,
force_format="WEBP",
height=70,
null=True,
upload_to="products",
),
),
]

View File

@ -37,6 +37,7 @@ from django_countries.fields import CountryField
from accounting.models import CurrencyField
from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
@ -208,7 +209,9 @@ class ProductType(models.Model):
name = models.CharField(_("name"), max_length=30)
description = models.TextField(_("description"), null=True, blank=True)
comment = models.TextField(_("comment"), null=True, blank=True)
icon = models.ImageField(upload_to="products", null=True, blank=True)
icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
)
# priority holds no real backend logic but helps to handle the order in which
# the items are to be shown to the user
@ -250,8 +253,13 @@ class Product(models.Model):
purchase_price = CurrencyField(_("purchase price"))
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(_("special selling price"))
icon = models.ImageField(
upload_to="products", null=True, blank=True, verbose_name=_("icon")
icon = ResizedImageField(
height=70,
force_format="WEBP",
upload_to="products",
null=True,
blank=True,
verbose_name=_("icon"),
)
club = models.ForeignKey(
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE

View File

@ -0,0 +1,6 @@
::: core.fields
handler: python
options:
members:
- ResizedImageFieldFile
- ResizedImageField

View File

@ -92,6 +92,7 @@ nav:
- reference/com/views.md
- core:
- reference/core/models.md
- Champs de modèle: reference/core/model_fields.md
- reference/core/views.md
- reference/core/schemas.md
- reference/core/api_permissions.md