1+ from __future__ import annotations
2+
3+ from io import BufferedReader , BytesIO
4+ from typing import TYPE_CHECKING
5+ from uuid import UUID
6+
7+ from django .core .files .uploadedfile import (
8+ InMemoryUploadedFile ,
9+ TemporaryUploadedFile ,
10+ )
11+ from django .db .models .fields .files import FieldFile , ImageFieldFile
12+
113from PIL import Image , UnidentifiedImageError
214from PIL .JpegImagePlugin import JpegImageFile
315from PIL .PngImagePlugin import PngImageFile
416from PIL .WebPImagePlugin import WebPImageFile
517
618from .app_settings import app_settings
719from .constants import TypeChoices
8- from .utils import get_file_path
20+ from .utils import get_md5_checksum , get_paths , safe_remove_file
21+
22+
23+ if TYPE_CHECKING :
24+ from .models import FileManager
25+ from .typed import File
926
1027
1128class BaseOptimizer :
1229 """Base Optimizer"""
1330
14- def __init__ (self , instance , file , * args , ** kwargs ):
31+ def __init__ (self , instance : FileManager , file : File , * args , ** kwargs ):
1532 self ._instance = instance
1633 self ._file = file
1734
1835 @property
19- def instance (self ):
36+ def instance (self ) -> FileManager :
2037 return self ._instance
2138
2239 @property
23- def file (self ):
40+ def file (self ) -> File :
2441 return self ._file
2542
26- def optimize (self ):
43+ @classmethod
44+ def open (
45+ cls , fp : str | bytes | BytesIO | BufferedReader | FieldFile | ImageFieldFile
46+ ) -> None | BufferedReader | BytesIO :
47+ return cls ._open (fp )
48+
49+ @classmethod
50+ def _open (
51+ cls , fp : str | bytes | BytesIO | BufferedReader | FieldFile | ImageFieldFile
52+ ) -> None | BufferedReader | BytesIO :
53+ if isinstance (fp , str ):
54+ return open (fp , "rb" )
55+ if isinstance (fp , bytes ):
56+ fp = BytesIO (fp )
57+ if isinstance (fp , (BytesIO , BufferedReader )):
58+ return fp
59+ if isinstance (fp , (FieldFile , ImageFieldFile )):
60+ return fp .file .file
61+
62+ @classmethod
63+ def close (cls , fp : BufferedReader | BytesIO | FieldFile | ImageFieldFile ) -> None :
64+ if isinstance (fp , (FieldFile , ImageFieldFile )):
65+ fp .close ()
66+ else :
67+ if fp and bool (getattr (fp , "closed" , None ) is False ):
68+ fp .close ()
69+
70+ @classmethod
71+ def checksum (cls , fp : str | bytes | InMemoryUploadedFile | TemporaryUploadedFile ):
72+ return get_md5_checksum (fp )
73+
74+ @classmethod
75+ def get_identifier (cls , fp : str | bytes ) -> UUID :
76+ return UUID (hex = cls .checksum (fp ))
77+
78+ def run (self ):
2779 pass
2880
2981
@@ -41,68 +93,154 @@ def __init__(
4193 ):
4294 super ().__init__ (instance , file , * args , ** kwargs )
4395
44- def open (self ) -> Image .Image | None :
96+ def run (self ):
97+ image , path = self .optimize (self .file .save_path , upload_to = self .file ._upload_to )
98+ self .file .path = path
99+ self .close (image )
100+
101+ @classmethod
102+ def open (
103+ cls , fp : str | bytes | BytesIO | BufferedReader | FieldFile | ImageFieldFile
104+ ) -> None | Image .Image :
45105 try :
46- image = Image .open (self . file . file )
106+ image = Image .open (cls . _open ( fp ) )
47107 return image
48- except UnidentifiedImageError :
108+ except ( UnidentifiedImageError , FileNotFoundError ) :
49109 pass
50110
51- def close (self , image : Image .Image ) -> None :
52- if isinstance (image , Image .Image ):
53- image .close ()
54-
55- def optimize (self ) -> None :
56- resized_img = self .open ()
57- if not resized_img :
111+ @classmethod
112+ def close (
113+ cls , fp : BufferedReader | BytesIO | Image .Image | FieldFile | ImageFieldFile
114+ ) -> None :
115+ if isinstance (fp , (Image .Image , FieldFile , ImageFieldFile )):
116+ fp .close ()
117+ else :
118+ super ().close (fp )
119+
120+ @classmethod
121+ def optimize (
122+ cls ,
123+ fp : str | bytes | BytesIO | BufferedReader | FieldFile | ImageFieldFile ,
124+ * ,
125+ filename : str = None ,
126+ upload_to : str = None ,
127+ box : tuple [int , int , int , int ] = None ,
128+ max_width : int = app_settings .image_optimizer .max_width ,
129+ max_height : int = app_settings .image_optimizer .max_height ,
130+ to_webp : bool = app_settings .image_optimizer .to_webp ,
131+ remove_origin : bool = app_settings .image_optimizer .remove_origin ,
132+ ) -> tuple [Image .Image , str ]:
133+ """Optimize the Image File
134+
135+ Args:
136+ fp: File path or file object.
137+ filename: Rename the original file.
138+ upload_to: Upload dir.
139+ box: The crop rectangle, as a (left, upper, right, lower)-tuple to crop the image.
140+ max_width: Max width of the image to resize.
141+ max_height: Max height of the image to resize.
142+ to_webp: Force convert image to webp type.
143+ remove_origin: Force to delete original image after optimization.
144+
145+ Returns:
146+ The Tuple: PIL Image, Image file path location. If the file is not in the correct format, a tuple with the value (None, None) can be returned.
147+ """
148+
149+ image = cls .open (fp )
150+ path = None
151+ if not isinstance (image , Image .Image ):
58152 return
59153
60154 fm , ext = None , None
61- if isinstance (resized_img , PngImageFile ):
155+ if isinstance (image , PngImageFile ):
62156 fm , ext = "PNG" , ".png"
63- resized_img = resized_img .convert ("P" , palette = Image .ADAPTIVE )
64- elif isinstance (resized_img , JpegImageFile ):
157+ image = image .convert ("P" , palette = Image .ADAPTIVE )
158+ elif isinstance (image , JpegImageFile ):
65159 fm , ext = "JPEG" , ".jpg"
66- elif isinstance (resized_img , WebPImageFile ):
160+ elif isinstance (image , WebPImageFile ):
67161 fm , ext = "WEBP" , ".webp"
68162
69163 if app_settings .image_optimizer .to_webp :
70164 fm , ext = "WEBP" , ".webp"
71165
72- if str (ext ) in self ._supported_file_types :
73- resized_img = self .resize (resized_img )
74- orig_save_path = self .file .save_path
75- filename = self .file .repl_filename + ext
76- self .file .path = get_file_path (filename , self .file ._upload_to )
77- resized_img .save (
78- self .file .save_path ,
166+ if str (ext ) in cls ._supported_file_types :
167+ image = cls .crop (image , box = box )
168+ image = cls .resize (image , max_width , max_height )
169+ if not filename and not isinstance (filename , str ):
170+ filename = str (cls .get_identifier (fp ))
171+
172+ filename = filename + ext
173+ save_path , path = get_paths (filename , upload_to = upload_to )
174+ image .save (
175+ save_path ,
79176 fm ,
80177 optimize = True ,
81178 quality = app_settings .image_optimizer .quality ,
82179 compress_level = app_settings .image_optimizer .compress_level ,
83180 )
84- if orig_save_path != self .file .save_path :
85- self .instance .file .delete ()
86- self .file .extension = ext
87181
88- self .close (resized_img )
182+ if remove_origin :
183+ if isinstance (fp , (FieldFile , ImageFieldFile )):
184+ fp .delete (save = False )
185+ else :
186+ origin_fp = (
187+ getattr (fp , "name" , None )
188+ if isinstance (fp , (BytesIO , BufferedReader ))
189+ else fp
190+ )
191+ if (
192+ origin_fp
193+ and isinstance (origin_fp , str )
194+ and path not in origin_fp
195+ ):
196+ safe_remove_file (origin_fp )
197+ return image , path
198+
199+ @classmethod
200+ def crop (
201+ cls , image : Image .Image , box : tuple [int , int , int , int ] = None
202+ ) -> Image .Image :
203+ """Crop an image
204+
205+ Args:
206+ image: PIL image object.
207+ box: The crop rectangle, as a (left, upper, right, lower)-tuple.
208+
209+ Returns:
210+ Returns a rectangular region from this PIL image.
211+ """
212+ if image and box is not None :
213+ return image .crop (box )
214+ return image
215+
216+ @classmethod
217+ def resize (
218+ cls ,
219+ image : Image .Image ,
220+ width : int = app_settings .image_optimizer .max_width ,
221+ height : int = app_settings .image_optimizer .max_height ,
222+ ) -> Image .Image :
223+ """Resize image to fit with Width and Height
224+
225+ Args:
226+ image: PIL image object.
227+ width: Max width to resize.
228+ height: Max height to resize.
89229
90- def resize (self , image : Image .Image ) -> Image .Image :
91- """Resize image to fit with max width and max height"""
230+ Returns:
231+ PIL image after resizing
232+ """
92233
93234 w , h = image .size
94235 aspect_ratio = w / h
95236
96- if (
97- w > app_settings .image_optimizer .max_width
98- or h > app_settings .image_optimizer .max_height
99- ):
237+ if w > width or h > height :
100238 if aspect_ratio > 1 :
101- nw = app_settings . image_optimizer . max_width
102- nh = int (app_settings . image_optimizer . max_width / aspect_ratio )
239+ nw = width
240+ nh = int (width / aspect_ratio )
103241 else :
104- nh = app_settings . image_optimizer . max_height
105- nw = int (app_settings . image_optimizer . max_height * aspect_ratio )
242+ nh = height
243+ nw = int (height * aspect_ratio )
106244
107245 return image .resize ((nw , nh ), Image .LANCZOS )
108246 return image
0 commit comments