├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── CHANGES.rst ├── MANIFEST.in ├── README.rst ├── django_resized ├── __init__.py ├── forms.py └── tests │ ├── __init__.py │ ├── models.py │ ├── requirements.txt │ ├── settings.py │ └── tests.py ├── media ├── big.jpg ├── exif.jpg └── small.png ├── setup.py └── tox.ini /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | tests_python: 10 | name: Test on Python ${{ matrix.python_version }} and Django ${{ matrix.django_version }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | django_version: [ '3.2', '4.0', '4.1', '4.2', '5.0', '5.1' ] 15 | python_version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ] 16 | exclude: 17 | 18 | - django_version: '4.0' 19 | python_version: '3.7' 20 | 21 | - django_version: '4.1' 22 | python_version: '3.7' 23 | 24 | - django_version: '4.2' 25 | python_version: '3.7' 26 | 27 | - django_version: '5.0' 28 | python_version: '3.7' 29 | 30 | - django_version: '5.0' 31 | python_version: '3.8' 32 | 33 | - django_version: '5.0' 34 | python_version: '3.9' 35 | 36 | - django_version: '5.1' 37 | python_version: '3.7' 38 | 39 | - django_version: '5.1' 40 | python_version: '3.8' 41 | 42 | - django_version: '5.1' 43 | python_version: '3.9' 44 | 45 | - django_version: '3.2' 46 | python_version: '3.12' 47 | 48 | - django_version: '3.2' 49 | python_version: '3.11' 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Set up Python ${{ matrix.python_version }} 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: ${{ matrix.python_version }} 59 | - name: Cache pip 60 | uses: actions/cache@v3 61 | with: 62 | # This path is specific to Ubuntu 63 | path: ~/.cache/pip 64 | key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ matrix.django_version }} 65 | - name: Install dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | pip install -e . 69 | pip install -U flake8 coveralls argparse -r django_resized/tests/requirements.txt 70 | pip install -U Django~=${{ matrix.django_version }} 71 | - name: Lint with flake8 72 | run: | 73 | flake8 --ignore=E501,W504 django_resized 74 | - name: Test Django 75 | run: | 76 | python -W error::DeprecationWarning -W error::PendingDeprecationWarning \ 77 | -m coverage run `which django-admin` test --settings=django_resized.tests.settings 78 | # - name: Coverage 79 | # if: ${{ success() }} 80 | # run: | 81 | # coveralls --service=github 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/git,macos,windows,linux,python,pycharm,intellij 3 | 4 | ### Git ### 5 | *.orig 6 | 7 | ### Intellij ### 8 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 9 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 10 | 11 | # User-specific stuff: 12 | .idea/**/workspace.xml 13 | .idea/**/tasks.xml 14 | .idea/dictionaries 15 | 16 | # Sensitive or high-churn files: 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.xml 20 | .idea/**/dataSources.local.xml 21 | .idea/**/sqlDataSources.xml 22 | .idea/**/dynamic.xml 23 | .idea/**/uiDesigner.xml 24 | 25 | # Gradle: 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # CMake 30 | cmake-build-debug/ 31 | 32 | # Mongo Explorer plugin: 33 | .idea/**/mongoSettings.xml 34 | 35 | ## File-based project format: 36 | *.iws 37 | 38 | ## Plugin-specific files: 39 | 40 | # IntelliJ 41 | /out/ 42 | 43 | # mpeltonen/sbt-idea plugin 44 | .idea_modules/ 45 | 46 | # JIRA plugin 47 | atlassian-ide-plugin.xml 48 | 49 | # Cursive Clojure plugin 50 | .idea/replstate.xml 51 | 52 | # Crashlytics plugin (for Android Studio and IntelliJ) 53 | com_crashlytics_export_strings.xml 54 | crashlytics.properties 55 | crashlytics-build.properties 56 | fabric.properties 57 | 58 | ### Intellij Patch ### 59 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 60 | 61 | # *.iml 62 | # modules.xml 63 | # .idea/misc.xml 64 | # *.ipr 65 | 66 | # Sonarlint plugin 67 | .idea/sonarlint 68 | 69 | ### Linux ### 70 | *~ 71 | 72 | # temporary files which can be created if a process still has a handle open of a deleted file 73 | .fuse_hidden* 74 | 75 | # KDE directory preferences 76 | .directory 77 | 78 | # Linux trash folder which might appear on any partition or disk 79 | .Trash-* 80 | 81 | # .nfs files are created when an open file is removed but is still being accessed 82 | .nfs* 83 | 84 | ### macOS ### 85 | *.DS_Store 86 | .AppleDouble 87 | .LSOverride 88 | 89 | # Icon must end with two \r 90 | Icon 91 | 92 | # Thumbnails 93 | ._* 94 | 95 | # Files that might appear in the root of a volume 96 | .DocumentRevisions-V100 97 | .fseventsd 98 | .Spotlight-V100 99 | .TemporaryItems 100 | .Trashes 101 | .VolumeIcon.icns 102 | .com.apple.timemachine.donotpresent 103 | 104 | # Directories potentially created on remote AFP share 105 | .AppleDB 106 | .AppleDesktop 107 | Network Trash Folder 108 | Temporary Items 109 | .apdisk 110 | 111 | ### PyCharm ### 112 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 113 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 114 | 115 | # User-specific stuff: 116 | 117 | # Sensitive or high-churn files: 118 | 119 | # Gradle: 120 | 121 | # CMake 122 | 123 | # Mongo Explorer plugin: 124 | 125 | ## File-based project format: 126 | 127 | ## Plugin-specific files: 128 | 129 | # IntelliJ 130 | 131 | # mpeltonen/sbt-idea plugin 132 | 133 | # JIRA plugin 134 | 135 | # Cursive Clojure plugin 136 | 137 | # Crashlytics plugin (for Android Studio and IntelliJ) 138 | 139 | ### PyCharm Patch ### 140 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 141 | 142 | # *.iml 143 | # modules.xml 144 | # .idea/misc.xml 145 | # *.ipr 146 | 147 | # Sonarlint plugin 148 | 149 | ### Python ### 150 | # Byte-compiled / optimized / DLL files 151 | __pycache__/ 152 | *.py[cod] 153 | *$py.class 154 | 155 | # C extensions 156 | *.so 157 | 158 | # Distribution / packaging 159 | .Python 160 | env/ 161 | build/ 162 | develop-eggs/ 163 | dist/ 164 | downloads/ 165 | eggs/ 166 | .eggs/ 167 | lib/ 168 | lib64/ 169 | parts/ 170 | sdist/ 171 | var/ 172 | wheels/ 173 | *.egg-info/ 174 | .installed.cfg 175 | *.egg 176 | 177 | # PyInstaller 178 | # Usually these files are written by a python script from a template 179 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 180 | *.manifest 181 | *.spec 182 | 183 | # Installer logs 184 | pip-log.txt 185 | pip-delete-this-directory.txt 186 | 187 | # Unit test / coverage reports 188 | htmlcov/ 189 | .tox/ 190 | .coverage 191 | .coverage.* 192 | .cache 193 | nosetests.xml 194 | coverage.xml 195 | *,cover 196 | .hypothesis/ 197 | 198 | # Translations 199 | *.mo 200 | *.pot 201 | 202 | # Django stuff: 203 | *.log 204 | local_settings.py 205 | 206 | # Flask stuff: 207 | instance/ 208 | .webassets-cache 209 | 210 | # Scrapy stuff: 211 | .scrapy 212 | 213 | # Sphinx documentation 214 | docs/_build/ 215 | 216 | # PyBuilder 217 | target/ 218 | 219 | # Jupyter Notebook 220 | .ipynb_checkpoints 221 | 222 | # pyenv 223 | .python-version 224 | 225 | # celery beat schedule file 226 | celerybeat-schedule 227 | 228 | # SageMath parsed files 229 | *.sage.py 230 | 231 | # dotenv 232 | .env 233 | 234 | # virtualenv 235 | .venv 236 | venv/ 237 | ENV/ 238 | 239 | # Spyder project settings 240 | .spyderproject 241 | .spyproject 242 | 243 | # Rope project settings 244 | .ropeproject 245 | 246 | # mkdocs documentation 247 | /site 248 | 249 | ### Windows ### 250 | # Windows thumbnail cache files 251 | Thumbs.db 252 | ehthumbs.db 253 | ehthumbs_vista.db 254 | 255 | # Folder config file 256 | Desktop.ini 257 | 258 | # Recycle Bin used on file shares 259 | $RECYCLE.BIN/ 260 | 261 | # Windows Installer files 262 | *.cab 263 | *.msi 264 | *.msm 265 | *.msp 266 | 267 | # Windows shortcuts 268 | *.lnk 269 | 270 | # End of https://www.gitignore.io/api/git,macos,windows,linux,python,pycharm,intellij -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | 1.0.4 (unreleased) 5 | ------------------ 6 | 7 | - 8 | 9 | 1.0.3 (2024-12-14) 10 | ------------------ 11 | 12 | - Fix conversion issues when converting from formats with alpha channel(png,webp) to jpeg format #59 13 | - Tests against django 5.1 #60 14 | 15 | 1.0.2 (2022-08-16) 16 | ------------------ 17 | 18 | - Add an error check for crop with single size dimension #43 19 | - Add an error check for webp conversion without quality set #45 20 | 21 | 1.0.1 (2022-06-29) 22 | ------------------- 23 | 24 | - Implement scale up and down support #42 25 | - Add support to maintain image ratio #23 26 | 27 | 1.0.0 (2022-05-03) 28 | ------------------- 29 | 30 | - Remove official support for Django < 2.2 and python 2 (it may still works but is untested) 31 | - Added support for Django up to 4.0 32 | - Add support for mirrored orientations #29 33 | - Fix JPEG default quality (fixes #34) #35 34 | - Add 'png' to the formats that need the img mode to be RGBA #39 #41 35 | - Use Image.Resampling.LANCZOS instead of deprecated Image.ANTIALIAS 36 | 37 | 0.3.11 38 | ------ 39 | 40 | - Check force_format exists before checking value 41 | 42 | 0.3.10 43 | ------ 44 | 45 | - Improvement: Remove EXIF information without creating new image 46 | - Convert GIF to JPG #19 47 | 48 | 0.3.9 49 | ----- 50 | 51 | - Feature: optional manualy setup the extensions for image types in setting DJANGORESIZED_DEFAULT_FORMAT_EXTENSIONS. 52 | - Feature: switch on/off normalize_rotation in setting DJANGORESIZED_DEFAULT_NORMALIZE_ROTATION. 53 | - Fix typo in DJANGORESIZED_DEFAULT_FORCE_FORMAT settings name in code. 54 | 55 | 0.3.8 56 | ----- 57 | 58 | - Feature: added force_format. 59 | 60 | 0.3.7 61 | ----- 62 | 63 | - Fix: error when orientation exif data is empty. 64 | 65 | 0.3.6 66 | ----- 67 | 68 | - Fix: add a deconstruct method. 69 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/un1t/django-resized/actions/workflows/python-app.yml/badge.svg 2 | :target: https://github.com/un1t/django-resized/actions/workflows/python-app.yml 3 | 4 | Resizes image origin to specified size. Compatible with sorl-thumbnail. Inherits from ImageField. 5 | 6 | Features 7 | ======== 8 | 9 | - Tested on Django 3.2, 4.0, 4.1, 4.2, 5.0, 5.1 10 | 11 | Installation 12 | ============ 13 | 14 | .. code-block:: bash 15 | 16 | pip install django-resized 17 | 18 | 19 | Configuration (optional) 20 | ======================== 21 | 22 | settings.py 23 | 24 | .. code-block:: python 25 | 26 | DJANGORESIZED_DEFAULT_SIZE = [1920, 1080] 27 | DJANGORESIZED_DEFAULT_SCALE = 0.5 28 | DJANGORESIZED_DEFAULT_QUALITY = 75 29 | DJANGORESIZED_DEFAULT_KEEP_META = True 30 | DJANGORESIZED_DEFAULT_FORCE_FORMAT = 'JPEG' 31 | DJANGORESIZED_DEFAULT_FORMAT_EXTENSIONS = {'JPEG': ".jpg"} 32 | DJANGORESIZED_DEFAULT_NORMALIZE_ROTATION = True 33 | 34 | 35 | Usage 36 | ===== 37 | 38 | models.py 39 | 40 | .. code-block:: python 41 | 42 | from django_resized import ResizedImageField 43 | 44 | class MyModel(models.Model): 45 | ... 46 | image1 = ResizedImageField(size=[500, 300], upload_to='whatever') 47 | image2 = ResizedImageField(size=[100, 100], crop=['top', 'left'], upload_to='whatever') 48 | image3 = ResizedImageField(size=[100, 150], crop=['middle', 'center'], upload_to='whatever') 49 | image4 = ResizedImageField(scale=0.5, quality=75, upload_to='whatever') 50 | image5 = ResizedImageField(size=None, upload_to='whatever', force_format='PNG') 51 | image6 = ResizedImageField(size=[100, None], upload_to='whatever') 52 | 53 | Options 54 | ------- 55 | 56 | 57 | - **size** - max width and height, for example [640, 480]. If a dimension is None, it will resized using the other value and maintains the ratio of the image. If size is None, the original size of the image will be kept. 58 | - **scale** - a float, if not None, which will rescale the image after the image has been resized. 59 | - **crop** - resize and crop. ['top', 'left'] - top left corner, ['middle', 'center'] is center cropping, ['bottom', 'right'] - crop right bottom corner. 60 | - **quality** - quality of resized image 0..100, -1 means default 61 | - **keep_meta** - keep EXIF and other meta data, default True 62 | - **force_format** - force the format of the resized image, available formats are the one supported by `pillow `_, default to None 63 | 64 | 65 | How to run tests 66 | ================ 67 | 68 | .. code-block:: bash 69 | 70 | pip install tox 71 | tox 72 | -------------------------------------------------------------------------------- /django_resized/__init__.py: -------------------------------------------------------------------------------- 1 | from django_resized.forms import ResizedImageField # noqa 2 | -------------------------------------------------------------------------------- /django_resized/forms.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from io import BytesIO 3 | from PIL import Image, ImageFile, ImageOps, ExifTags 4 | from django.conf import settings 5 | from django.core import checks 6 | from django.core.files.base import ContentFile 7 | 8 | try: 9 | from sorl.thumbnail import ImageField 10 | except ImportError: 11 | from django.db.models import ImageField 12 | 13 | 14 | DEFAULT_SIZE = getattr(settings, 'DJANGORESIZED_DEFAULT_SIZE', [1920, 1080]) 15 | DEFAULT_SCALE = getattr(settings, 'DJANGORESIZED_DEFAULT_SCALE', None) 16 | DEFAULT_QUALITY = getattr(settings, 'DJANGORESIZED_DEFAULT_QUALITY', -1) 17 | DEFAULT_KEEP_META = getattr(settings, 'DJANGORESIZED_DEFAULT_KEEP_META', True) 18 | DEFAULT_FORCE_FORMAT = getattr(settings, 'DJANGORESIZED_DEFAULT_FORCE_FORMAT', None) 19 | DEFAULT_FORMAT_EXTENSIONS = getattr(settings, 'DJANGORESIZED_DEFAULT_FORMAT_EXTENSIONS', {}) 20 | DEFAULT_NORMALIZE_ROTATION = getattr(settings, 'DJANGORESIZED_DEFAULT_NORMALIZE_ROTATION', True) 21 | 22 | 23 | def normalize_rotation(image): 24 | """ 25 | Find orientation header and rotate the actual data instead. 26 | Adapted from http://stackoverflow.com/a/6218425/723090 27 | """ 28 | try: 29 | image._getexif() 30 | except AttributeError: 31 | # No exit data; this image is not a jpg and can be skipped 32 | return image 33 | 34 | for orientation in ExifTags.TAGS.keys(): 35 | # Look for orientation header, stop when found 36 | if ExifTags.TAGS[orientation] == 'Orientation': 37 | break 38 | else: 39 | # No orientation header found, do nothing 40 | return image 41 | # Apply the different possible orientations to the data; preserve format 42 | format = image.format 43 | exif = image._getexif() 44 | if exif is None: 45 | return image 46 | action_nr = exif.get(orientation, None) 47 | if action_nr is None: 48 | # Empty orientation exif data 49 | return image 50 | if action_nr in (3, 4): 51 | image = image.rotate(180, expand=True) 52 | elif action_nr in (5, 6): 53 | image = image.rotate(270, expand=True) 54 | elif action_nr in (7, 8): 55 | image = image.rotate(90, expand=True) 56 | if action_nr in (2, 4, 5, 7): 57 | image = ImageOps.mirror(image) 58 | image.format = format 59 | return image 60 | 61 | def convert_mode_for_format(to_format, image): 62 | """ 63 | Converts the mode of image to 'RGB' or 'RGBA' depending on format. 64 | """ 65 | from_format = image.format.lower() 66 | to_format = to_format.lower() 67 | transparent_bg_fill_color = (0,0,0,0) 68 | 69 | if from_format in ('jpg', 'jpeg') and to_format in ('png', 'webp'): 70 | image = image.convert('RGBA') 71 | 72 | if from_format in ('png', 'webp'): 73 | image = image.convert('RGBA') if image.mode != 'RGBA' else image 74 | if to_format in ('jpg', 'jpeg'): 75 | bg = Image.new('RGBA', image.size, transparent_bg_fill_color) 76 | image = Image.alpha_composite(bg, image).convert('RGB') 77 | 78 | return image 79 | 80 | 81 | class ResizedImageFieldFile(ImageField.attr_class): 82 | 83 | def save(self, name, content, save=True): 84 | content.file.seek(0) 85 | img = Image.open(content.file) 86 | 87 | if DEFAULT_NORMALIZE_ROTATION: 88 | img = normalize_rotation(img) 89 | 90 | if self.field.force_format: 91 | img = convert_mode_for_format(self.field.force_format, img) 92 | 93 | try: 94 | # Replace ANTIALIAS in PIL 9 95 | resample = Image.Resampling.LANCZOS 96 | except AttributeError: 97 | resample = Image.ANTIALIAS 98 | 99 | if self.field.size is None: 100 | self.field.size = img.size 101 | 102 | if self.field.crop: 103 | thumb = ImageOps.fit( 104 | img, 105 | self.field.size, 106 | resample, 107 | centering=self.get_centring() 108 | ) 109 | elif None in self.field.size: 110 | thumb = img 111 | if self.field.size[0] is None and self.field.size[1] is not None: 112 | self.field.scale = self.field.size[1] / img.size[1] 113 | elif self.field.size[1] is None and self.field.size[0] is not None: 114 | self.field.scale = self.field.size[0] / img.size[0] 115 | else: 116 | img.thumbnail( 117 | self.field.size, 118 | resample, 119 | ) 120 | thumb = img 121 | 122 | if self.field.scale is not None: 123 | thumb = ImageOps.scale( 124 | thumb, 125 | self.field.scale, 126 | resample 127 | ) 128 | 129 | img_info = img.info 130 | if not self.field.keep_meta: 131 | img_info.pop('exif', None) 132 | 133 | ImageFile.MAXBLOCK = max(ImageFile.MAXBLOCK, thumb.size[0] * thumb.size[1]) 134 | new_content = BytesIO() 135 | img_format = img.format if self.field.force_format is None else self.field.force_format 136 | thumb.save(new_content, format=img_format, quality=self.field.quality, **img_info) 137 | new_content = ContentFile(new_content.getvalue()) 138 | 139 | name = self.get_name(name, img_format) 140 | super().save(name, new_content, save) 141 | 142 | def get_name(self, name, format): 143 | extensions = Image.registered_extensions() 144 | extensions = {v: k for k, v in extensions.items()} 145 | extensions.update({ 146 | "PNG": ".png", # It uses .apng otherwise 147 | }) 148 | extensions.update(DEFAULT_FORMAT_EXTENSIONS) 149 | if format in extensions: 150 | name = name.rsplit('.', 1)[0] + extensions[format] 151 | return name 152 | 153 | def get_centring(self): 154 | vertical = { 155 | 'top': 0, 156 | 'middle': 0.5, 157 | 'bottom': 1, 158 | } 159 | horizontal = { 160 | 'left': 0, 161 | 'center': 0.5, 162 | 'right': 1, 163 | } 164 | return [ 165 | vertical[self.field.crop[0]], 166 | horizontal[self.field.crop[1]], 167 | ] 168 | 169 | 170 | class ResizedImageField(ImageField): 171 | 172 | attr_class = ResizedImageFieldFile 173 | 174 | def __init__(self, verbose_name=None, name=None, **kwargs): 175 | # migrate from 0.2.x 176 | for argname in ('max_width', 'max_height', 'use_thumbnail_aspect_ratio', 'background_color'): 177 | if argname in kwargs: 178 | warnings.warn( 179 | f'Error: Keyword argument {argname} is deprecated for ResizedImageField, ' 180 | 'see README https://github.com/un1t/django-resized', 181 | DeprecationWarning, 182 | ) 183 | del kwargs[argname] 184 | 185 | self.size = kwargs.pop('size', DEFAULT_SIZE) 186 | self.scale = kwargs.pop('scale', DEFAULT_SCALE) 187 | self.crop = kwargs.pop('crop', None) 188 | self.quality = kwargs.pop('quality', DEFAULT_QUALITY) 189 | self.keep_meta = kwargs.pop('keep_meta', DEFAULT_KEEP_META) 190 | self.force_format = kwargs.pop('force_format', DEFAULT_FORCE_FORMAT) 191 | super().__init__(verbose_name, name, **kwargs) 192 | 193 | def deconstruct(self): 194 | name, path, args, kwargs = super().deconstruct() 195 | for custom_kwargs in ('crop', 'size', 'scale', 'quality', 'keep_meta', 'force_format'): 196 | kwargs[custom_kwargs] = getattr(self, custom_kwargs) 197 | return name, path, args, kwargs 198 | 199 | def check(self, **kwargs): 200 | return [ 201 | *super().check(**kwargs), 202 | *self._check_single_dimension_crop(), 203 | *self._check_webp_quality(), 204 | ] 205 | 206 | def _check_single_dimension_crop(self): 207 | if self.crop is not None and self.size is not None and None in self.size: 208 | return [ 209 | checks.Error( 210 | f"{self.__class__.__name__} has both a crop argument and a single dimension size. " 211 | "Crop is not possible in that case as the second size dimension is computed from the " 212 | "image size and the image will never be cropped.", 213 | obj=self, 214 | id='django_resized.E100', 215 | hint='Remove the crop argument.', 216 | ) 217 | ] 218 | else: 219 | return [] 220 | 221 | def _check_webp_quality(self): 222 | if ( 223 | self.force_format is not None and 224 | self.force_format.lower() == 'webp' and 225 | (self.quality is None or self.quality == -1) 226 | ): 227 | return [ 228 | checks.Error( 229 | f"{self.__class__.__name__} forces the webp format without the quality set.", 230 | obj=self, 231 | id='django_resized.E101', 232 | hint='Set the quality argument.', 233 | ) 234 | ] 235 | else: 236 | return [] 237 | -------------------------------------------------------------------------------- /django_resized/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un1t/django-resized/658ec867590fc8f3ab1aa740613ff7918c269ef9/django_resized/tests/__init__.py -------------------------------------------------------------------------------- /django_resized/tests/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from django.db import models 3 | from django_resized import ResizedImageField 4 | 5 | UPLOAD_TO = 'tests' 6 | 7 | 8 | class Product(models.Model): 9 | image1 = ResizedImageField(size=[500, 350], upload_to=UPLOAD_TO, blank=True) 10 | image2 = ResizedImageField(upload_to=UPLOAD_TO) 11 | image3 = ResizedImageField(size=[40, 40], crop=['middle', 'center'], upload_to=UPLOAD_TO, blank=True) 12 | image4 = ResizedImageField(size=[100, 100], crop=['top', 'right'], upload_to=UPLOAD_TO, blank=True) 13 | image5 = ResizedImageField(size=[500, 350], upload_to=UPLOAD_TO, blank=True, quality=10) 14 | image6 = ResizedImageField(size=[500, 350], upload_to=UPLOAD_TO, keep_meta=False, blank=True, quality=10) 15 | image7 = ResizedImageField(size=[2000, 2000], upload_to=UPLOAD_TO, blank=True) 16 | image8 = ResizedImageField(size=[100, 100], crop=['top', 'right'], scale=2.0, upload_to=UPLOAD_TO, blank=True) 17 | image9 = ResizedImageField(size=[None, None], scale=0.5, upload_to=UPLOAD_TO, blank=True) 18 | image10 = ResizedImageField(size=[500, None], upload_to=UPLOAD_TO, blank=True) 19 | image11 = ResizedImageField(size=[None, 350], upload_to=UPLOAD_TO, blank=True) 20 | image12 = ResizedImageField(size=None, scale=0.5, upload_to=UPLOAD_TO, blank=True) 21 | image_force_png = ResizedImageField(size=[500, 350], upload_to=UPLOAD_TO, blank=True, force_format='PNG') 22 | -------------------------------------------------------------------------------- /django_resized/tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pillow 2 | -------------------------------------------------------------------------------- /django_resized/tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).parent.parent 4 | 5 | SECRET_KEY = 'secret' 6 | 7 | DEBUG = True 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | } 13 | } 14 | 15 | MIDDLEWARE_CLASSES = ( 16 | 'django.contrib.sessions.middleware.SessionMiddleware', 17 | 'django.middleware.common.CommonMiddleware', 18 | 'django.middleware.csrf.CsrfViewMiddleware', 19 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 20 | 'django.contrib.messages.middleware.MessageMiddleware', 21 | ) 22 | 23 | # New style default middleware declaration for Django 1.10+ 24 | MIDDLEWARE = [ 25 | 'django.middleware.security.SecurityMiddleware', 26 | 'django.contrib.sessions.middleware.SessionMiddleware', 27 | 'django.middleware.common.CommonMiddleware', 28 | 'django.middleware.csrf.CsrfViewMiddleware', 29 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 30 | 'django.contrib.messages.middleware.MessageMiddleware', 31 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 32 | ] 33 | 34 | INSTALLED_APPS = ( 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.admin', 39 | 'django.contrib.messages', 40 | 'django_resized.tests', 41 | ) 42 | 43 | TEMPLATES = [ 44 | { 45 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 46 | 'DIRS': [], 47 | 'APP_DIRS': True, 48 | 'OPTIONS': { 49 | 'context_processors': [ 50 | 'django.template.context_processors.debug', 51 | 'django.template.context_processors.request', 52 | 'django.contrib.auth.context_processors.auth', 53 | 'django.contrib.messages.context_processors.messages', 54 | ], 55 | }, 56 | }, 57 | ] 58 | 59 | # This is for Django 3.2, harmless for previous versions. 60 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 61 | 62 | # This is for Django 4.0, harmless for previous versions. 63 | USE_TZ = False 64 | 65 | DJANGORESIZED_DEFAULT_SIZE = [400, 300] 66 | 67 | MEDIA_ROOT = BASE_DIR / 'media' 68 | -------------------------------------------------------------------------------- /django_resized/tests/tests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import shutil 4 | from PIL import Image 5 | 6 | from django.conf import settings 7 | from django.core import checks 8 | from django.test import TestCase 9 | from django.core.files import File 10 | 11 | from django_resized import ResizedImageField 12 | from .models import Product 13 | 14 | 15 | class ResizeTest(TestCase): 16 | 17 | def tearDown(self): 18 | dirname = os.path.join(settings.MEDIA_ROOT, 'tests') 19 | if os.path.exists(dirname): 20 | shutil.rmtree(dirname) 21 | 22 | def test_resize_to_specified_size(self): 23 | product = Product.objects.create( 24 | image1=File(open('media/big.jpg', 'rb')), 25 | ) 26 | im1 = Image.open(product.image1.path) 27 | self.assertEqual(im1.size, (467, 350)) 28 | 29 | def test_resizes_settings_default(self): 30 | product = Product.objects.create( 31 | image2=File(open('media/big.jpg', 'rb')), 32 | ) 33 | im2 = Image.open(product.image2.path) 34 | self.assertEqual(im2.size, (400, 300)) 35 | 36 | def test_resize_crop_center(self): 37 | product = Product.objects.create( 38 | image3=File(open('media/big.jpg', 'rb')), 39 | ) 40 | im3 = Image.open(product.image3.path) 41 | self.assertEqual(im3.size, (40, 40)) 42 | 43 | def test_resize_crop_right(self): 44 | product = Product.objects.create( 45 | image4=File(open('media/big.jpg', 'rb')), 46 | ) 47 | im4 = Image.open(product.image4.path) 48 | self.assertEqual(im4.size, (100, 100)) 49 | 50 | def test_resize_with_quality(self): 51 | product = Product.objects.create( 52 | image1=File(open('media/big.jpg', 'rb')), 53 | image5=File(open('media/big.jpg', 'rb')), 54 | ) 55 | self.assertTrue(os.path.getsize(product.image1.path) > os.path.getsize(product.image5.path)) 56 | 57 | def test_keep_exif(self): 58 | product = Product.objects.create( 59 | image1=File(open('media/exif.jpg', 'rb')), 60 | ) 61 | self.assertTrue(self.has_exif(product.image1.path)) 62 | 63 | def test_remove_exif(self): 64 | product = Product.objects.create( 65 | image6=File(open('media/exif.jpg', 'rb')), 66 | ) 67 | self.assertFalse(self.has_exif(product.image6.path)) 68 | 69 | def has_exif(self, filename): 70 | return bool(Image.open(filename)._getexif()) 71 | 72 | def test_resize_without_upscale(self): 73 | product = Product.objects.create( 74 | image7=File(open('media/big.jpg', 'rb')), 75 | ) 76 | im7 = Image.open(product.image7.path) 77 | self.assertEqual(im7.size, (604, 453)) 78 | 79 | def test_scale_set_size(self): 80 | product = Product.objects.create( 81 | image8=File(open('media/big.jpg', 'rb')), 82 | ) 83 | im8 = Image.open(product.image8.path) 84 | self.assertEqual(im8.size, (200, 200)) 85 | 86 | def test_scale_default_size(self): 87 | product = Product.objects.create( 88 | image9=File(open('media/big.jpg', 'rb')), 89 | ) 90 | im9 = Image.open(product.image9.path) 91 | self.assertEqual(im9.size, (302, 226)) 92 | 93 | def test_scale_x(self): 94 | product = Product.objects.create( 95 | image10=File(open('media/big.jpg', 'rb')), 96 | ) 97 | im10 = Image.open(product.image10.path) 98 | self.assertEqual(im10.size, (500, 375)) 99 | 100 | def test_scale_y(self): 101 | product = Product.objects.create( 102 | image11=File(open('media/big.jpg', 'rb')), 103 | ) 104 | im11 = Image.open(product.image11.path) 105 | self.assertEqual(im11.size, (467, 350)) 106 | 107 | def test_scale_no_size(self): 108 | product = Product.objects.create( 109 | image12=File(open('media/big.jpg', 'rb')), 110 | ) 111 | im12 = Image.open(product.image12.path) 112 | self.assertEqual(im12.size, (302, 226)) 113 | 114 | def test_force_format(self): 115 | product = Product.objects.create( 116 | image_force_png=File(open('media/big.jpg', 'rb')), 117 | ) 118 | image_force_png = Image.open(product.image_force_png.path) 119 | self.assertEqual(image_force_png.format, 'PNG') 120 | self.assertTrue(image_force_png.filename.endswith('.png')) 121 | 122 | def test_force_webp_without_quality(self): 123 | self.assertIsInstance( 124 | ResizedImageField(force_format='WEBP', name='foo').check()[0], 125 | checks.Error 126 | ) 127 | 128 | def test_crop_single_size_dimension(self): 129 | self.assertIsInstance( 130 | ResizedImageField(size=[None, 350], crop=['top', 'right'], blank=True, name='foo').check()[0], 131 | checks.Error 132 | ) 133 | 134 | def test_resize_png(self): 135 | product = Product.objects.create( 136 | image2=File(open('media/small.png', 'rb')), 137 | ) 138 | im2 = Image.open(product.image2.path) 139 | self.assertEqual(im2.size, (50, 50)) 140 | self.assertEqual(im2.format, 'PNG') 141 | 142 | 143 | class ResizeFieldTest(TestCase): 144 | 145 | def test_clone(self): 146 | field = ResizedImageField(size=[500, 350], keep_meta=False, crop=['top', 'left'], quality=10) 147 | clone = field.clone() 148 | self.assertListEqual(clone.size, field.size) 149 | self.assertListEqual(clone.crop, field.crop) 150 | self.assertEqual(clone.keep_meta, field.keep_meta) 151 | self.assertEqual(clone.quality, field.quality) 152 | -------------------------------------------------------------------------------- /media/big.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un1t/django-resized/658ec867590fc8f3ab1aa740613ff7918c269ef9/media/big.jpg -------------------------------------------------------------------------------- /media/exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un1t/django-resized/658ec867590fc8f3ab1aa740613ff7918c269ef9/media/exif.jpg -------------------------------------------------------------------------------- /media/small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un1t/django-resized/658ec867590fc8f3ab1aa740613ff7918c269ef9/media/small.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2012 Ilya Shalyapin 5 | # 6 | # django-resized is free software under terms of the MIT License. 7 | # 8 | from setuptools import setup, find_packages 9 | 10 | 11 | setup( 12 | name = 'django-resized', 13 | version = '1.0.3', 14 | packages = ['django_resized'], 15 | requires = ['python (>= 3.7)', 'django (>= 3.2)'], 16 | description = 'Resizes image origin to specified size.', 17 | long_description = open('README.rst').read(), 18 | author = 'Ilya Shalyapin', 19 | author_email = 'ishalyapin@gmail.com', 20 | url = 'https://github.com/un1t/django-resized', 21 | download_url = 'https://github.com/un1t/django-resized/tarball/master', 22 | license = 'MIT License', 23 | keywords = 'django', 24 | classifiers = [ 25 | 'Environment :: Web Environment', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 3.2', 28 | 'Framework :: Django :: 4.1', 29 | 'Framework :: Django :: 4.0', 30 | 'Framework :: Django :: 4.2', 31 | 'Framework :: Django :: 5.0', 32 | 'Framework :: Django :: 5.1', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.7', 38 | 'Programming Language :: Python :: 3.8', 39 | 'Programming Language :: Python :: 3.9', 40 | 'Programming Language :: Python :: 3.10', 41 | 'Programming Language :: Python :: 3.11', 42 | 'Programming Language :: Python :: 3.12', 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = 4 | py{37,38,39,310}-django-{32} 5 | py{38,39,310,311,312}-django-{40,41,42} 6 | py{310,311,312}-django-{50,51} 7 | py310-flake8 8 | 9 | [testenv] 10 | setenv = 11 | PYTHONPATH = {toxinidir}:{toxinidir} 12 | deps = 13 | -rdjango_resized/tests/requirements.txt 14 | py310-flake8: flake8 15 | django-32: Django>=3.2,<4.0 16 | django-40: Django>=4.0,<4.1 17 | django-41: Django>=4.1,<4.2 18 | django-42: Django>=4.2,<4.3 19 | django-50: Django>=5.0,<5.1 20 | django-51: Django>=5.1,<5.2 21 | commands = 22 | flake8: flake8 django_resized --ignore=E501,W504 23 | django: django-admin test --settings=django_resized.tests.settings {posargs} 24 | --------------------------------------------------------------------------------