Skip to content

Commit c384287

Browse files
Merge pull request #12 from thewebscraping/dev
Dev
2 parents fd2323d + 9b31826 commit c384287

File tree

12 files changed

+488
-85
lines changed

12 files changed

+488
-85
lines changed

README.md

+48-20
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Features
88
----------
99
- Multiple file uploads.
1010
- Drag and Drop UI.
11-
- MD5 checksum file.
11+
- MD5 checksum file: check, validate, handle duplicate files.
1212
- Chunked uploads: optimizing large file transfers.
1313
- Prevent uploading existing files with MD5 checksum.
1414
- Easy to use any models.
@@ -57,27 +57,26 @@ Change default config: `settings.py`
5757

5858
```python
5959
DJANGO_CHUNK_FILE_UPLOAD = {
60-
"chunk_size": 1024 * 1024 * 2, # # custom chunk size upload (default: 2MB).
61-
"upload_to": "custom_folder/%Y/%m/%d", # custom upload folder.
62-
"is_metadata_storage": True, # save file metadata,
60+
"chunk_size": 1024 * 1024 * 2, # # Custom chunk size upload (default: 2MB).
61+
"upload_to": "uploads/%Y/%m/%d", # Custom upload folder.
62+
"is_metadata_storage": True, # Save file metadata,
6363
"remove_file_on_update": True,
6464
"optimize": True,
65-
"js": (
66-
"https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js",
67-
"https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js",
68-
"https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js",
69-
), # use cdn.
70-
"css": (
71-
"custom.css"
72-
), # custom css path.
7365
"image_optimizer": {
7466
"quality": 82,
7567
"compress_level": 9,
7668
"max_width": 1024,
7769
"max_height": 720,
78-
"to_webp": True, # focus convert image to webp type.
70+
"to_webp": True, # Force convert image to webp type.
71+
"remove_origin": True, # Force to delete original image after optimization.
7972
},
80-
"permission_classes": ("django_chunk_file_upload.permissions.AllowAny",) # default: IsAuthenticated
73+
"permission_classes": ("django_chunk_file_upload.permissions.AllowAny",), # default: IsAuthenticated
74+
# "js": (
75+
# "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js",
76+
# "https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js",
77+
# "https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js",
78+
# ), # custom js, use cdn.
79+
# "css": ("custom.css",), # custom your css path.
8180
}
8281

8382
```
@@ -129,13 +128,13 @@ class CustomChunkedUploadView(ChunkedUploadView):
129128
form_class = YourForm
130129
permission_classes = (IsAuthenticated,)
131130

132-
# file_class = File # file class
131+
# file_class = File # File handle class
133132
# file_status = app_settings.status # default: PENDING (Used when using background task, you can change it to COMPLETED.)
134133
# optimize = True # default: True
135-
# remove_file_on_update = True # update image on admin page.
136-
# chunk_size = 1024 * 1024 * 2 # custom chunk size upload (default: 2MB).
137-
# upload_to = "custom_folder/%Y/%m/%d" # custom upload folder.
138-
# template_name = "custom_template.html" # custom template
134+
# remove_file_on_update = True # Update image on admin page.
135+
# chunk_size = 1024 * 1024 * 2 # Custom chunk size upload (default: 2MB).
136+
# upload_to = "custom_folder/%Y/%m/%d" # Custom upload folder.
137+
# template_name = "custom_template.html" # Custom template
139138

140139
# # Run background task like celery when upload is complete
141140
# def background_task(self, instance):
@@ -189,4 +188,33 @@ from django_chunk_file_upload.typed import (
189188
)
190189
```
191190

192-
This package is under development, only supports create view. There are also no features related to image optimization. Use at your own risk.
191+
### Image Optimizer
192+
Use image Optimizer feature for other modules
193+
194+
```python
195+
from django_chunk_file_upload.optimize import ImageOptimizer
196+
from django_chunk_file_upload.app_settings import app_settings
197+
198+
# Image optimize method: resize, crop, delete, convert and optimize
199+
# This method calls two other methods:
200+
# ImageOptimizer.resize: resize image
201+
# ImageOptimizer.crop: example get parameters from Cropperjs (https://github.com/fengyuanchen/cropperjs).
202+
image, path = ImageOptimizer.optimize(
203+
fp='path/image.png',
204+
filename=None, # Rename the original file.
205+
upload_to=None, # Upload dir.
206+
box=None, # The crop rectangle, as a (left, upper, right, lower)-tuple to crop the image.
207+
max_width =app_settings.image_optimizer.max_width, # Max width of the image to resize.
208+
max_height=app_settings.image_optimizer.max_height, # Max height of the image to resize.
209+
to_webp=True, # Force convert image to webp type.
210+
remove_origin =app_settings.image_optimizer.remove_origin, # Force to delete original image after optimization.
211+
)
212+
```
213+
214+
### UnitTests
215+
216+
```shell
217+
python runtests.py
218+
```
219+
220+
Note: This package is under development, only supports create view. There are also no features related to image optimization. Use at your own risk.

django_chunk_file_upload/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
__title__ = "django-chunk-file-upload"
2-
__version__ = "1.0.2"
2+
__version__ = "1.0.3"
33
__author__ = "Tu Pham"
44
__license__ = "MIT"
55
__copyright__ = "Copyright 2024 Tu Pham and contributors"

django_chunk_file_upload/app_settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class _ImageSettings(_Settings):
2929
max_width: int = 1280
3030
max_height: int = 720
3131
to_webp: bool = True
32+
remove_origin: bool = True
3233

3334

3435
@dataclass(kw_only=True)

django_chunk_file_upload/optimize.py

+178-40
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,81 @@
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+
113
from PIL import Image, UnidentifiedImageError
214
from PIL.JpegImagePlugin import JpegImageFile
315
from PIL.PngImagePlugin import PngImageFile
416
from PIL.WebPImagePlugin import WebPImageFile
517

618
from .app_settings import app_settings
719
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
926

1027

1128
class 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

Comments
 (0)