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
+
1
13
from PIL import Image , UnidentifiedImageError
2
14
from PIL .JpegImagePlugin import JpegImageFile
3
15
from PIL .PngImagePlugin import PngImageFile
4
16
from PIL .WebPImagePlugin import WebPImageFile
5
17
6
18
from .app_settings import app_settings
7
19
from .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
9
26
10
27
11
28
class BaseOptimizer :
12
29
"""Base Optimizer"""
13
30
14
- def __init__ (self , instance , file , * args , ** kwargs ):
31
+ def __init__ (self , instance : FileManager , file : File , * args , ** kwargs ):
15
32
self ._instance = instance
16
33
self ._file = file
17
34
18
35
@property
19
- def instance (self ):
36
+ def instance (self ) -> FileManager :
20
37
return self ._instance
21
38
22
39
@property
23
- def file (self ):
40
+ def file (self ) -> File :
24
41
return self ._file
25
42
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 ):
27
79
pass
28
80
29
81
@@ -41,68 +93,154 @@ def __init__(
41
93
):
42
94
super ().__init__ (instance , file , * args , ** kwargs )
43
95
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 :
45
105
try :
46
- image = Image .open (self . file . file )
106
+ image = Image .open (cls . _open ( fp ) )
47
107
return image
48
- except UnidentifiedImageError :
108
+ except ( UnidentifiedImageError , FileNotFoundError ) :
49
109
pass
50
110
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 ):
58
152
return
59
153
60
154
fm , ext = None , None
61
- if isinstance (resized_img , PngImageFile ):
155
+ if isinstance (image , PngImageFile ):
62
156
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 ):
65
159
fm , ext = "JPEG" , ".jpg"
66
- elif isinstance (resized_img , WebPImageFile ):
160
+ elif isinstance (image , WebPImageFile ):
67
161
fm , ext = "WEBP" , ".webp"
68
162
69
163
if app_settings .image_optimizer .to_webp :
70
164
fm , ext = "WEBP" , ".webp"
71
165
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 ,
79
176
fm ,
80
177
optimize = True ,
81
178
quality = app_settings .image_optimizer .quality ,
82
179
compress_level = app_settings .image_optimizer .compress_level ,
83
180
)
84
- if orig_save_path != self .file .save_path :
85
- self .instance .file .delete ()
86
- self .file .extension = ext
87
181
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.
89
229
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
+ """
92
233
93
234
w , h = image .size
94
235
aspect_ratio = w / h
95
236
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 :
100
238
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 )
103
241
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 )
106
244
107
245
return image .resize ((nw , nh ), Image .LANCZOS )
108
246
return image
0 commit comments