├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .travis-yml.bak ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ ├── css │ │ └── custom.css │ └── images │ │ ├── ppoi-adjusted.jpg │ │ ├── ppoi-admin-example.png │ │ ├── ppoi-default.jpg │ │ ├── the-dowager-countess-crop-c0-44__0-22-400x400.jpg │ │ ├── the-dowager-countess-crop-c0-5__0-5-400x400.jpg │ │ ├── the-dowager-countess-thumbnail-200x200.jpg │ │ ├── the-dowager-countess.jpg │ │ ├── the-dowager-countess__invert__-thumbnail-200x200.jpg │ │ └── the-dowager-countess__invert__.jpg ├── _templates │ └── layout.html ├── conf.py ├── deleting_created_images.rst ├── drf_integration.rst ├── improving_performance.rst ├── index.rst ├── installation.rst ├── model_integration.rst ├── overview.rst ├── specifying_ppoi.rst ├── using_sizers_and_filters.rst └── writing_custom_sizers_and_filters.rst ├── post_processor_runtests.py ├── runtests.py ├── setup.cfg ├── setup.py ├── test_reqs.txt ├── tests ├── __init__.py ├── admin.py ├── forms.py ├── media │ ├── cmyk.jpg │ ├── delete-test │ │ └── python-logo-delete-test.jpg │ ├── exif-orientation-examples │ │ ├── Landscape_3.jpg │ │ ├── Landscape_6.jpg │ │ └── Landscape_8.jpg │ ├── foo │ │ └── python-logo.jpg │ ├── on-storage-placeholder │ │ └── placeholder.png │ ├── python-logo-2.jpg │ ├── python-logo-no-ext │ ├── python-logo.gif │ ├── python-logo.jpg │ ├── python-logo.png │ ├── python-logo.webp │ ├── transparent.gif │ └── verify-against │ │ ├── exif-orientation-examples │ │ ├── Landscape_3-thumbnail-100x100.jpg │ │ ├── Landscape_6-thumbnail-100x100.jpg │ │ └── Landscape_8-thumbnail-100x100.jpg │ │ ├── python-logo-crop-c0__0-100x30.gif │ │ ├── python-logo-crop-c0__0-30x100.gif │ │ ├── python-logo-crop-c1__1-100x30.gif │ │ └── python-logo-crop-c1__1-30x100.gif ├── models.py ├── placeholder.png ├── post_processor │ ├── __init__.py │ ├── discover_tests.py │ ├── models.py │ ├── post_processor_tests.py │ └── test_settings.py ├── serializers.py ├── settings_base.py ├── templates │ └── test-template.html ├── test.png ├── test2.png ├── test_autodiscover │ ├── __init__.py │ └── versatileimagefield.py ├── test_settings.py ├── tests.py └── urls.py ├── tox.ini └── versatileimagefield ├── __init__.py ├── apps.py ├── datastructures ├── __init__.py ├── base.py ├── filteredimage.py ├── mixins.py └── sizedimage.py ├── fields.py ├── files.py ├── forms.py ├── image_warmer.py ├── mixins.py ├── placeholder.py ├── processors ├── __init__.py └── hashlib_processors.py ├── registry.py ├── serializers.py ├── settings.py ├── static └── versatileimagefield │ ├── css │ ├── versatileimagefield-bootstrap3.css │ ├── versatileimagefield-djangoadmin.css │ └── versatileimagefield.css │ └── js │ └── versatileimagefield.js ├── templates └── versatileimagefield │ └── forms │ └── widgets │ ├── attrs.html │ ├── versatile_image.html │ └── versatile_image_bootstrap.html ├── utils.py ├── validators.py ├── versatileimagefield.py └── widgets.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests, Evaluate Coverage, Package & Release 2 | 3 | 4 | on: 5 | push: {} 6 | pull_request: 7 | types: 8 | - opened 9 | - reopened 10 | - synchronize 11 | branches: 12 | - master 13 | pull_request_target: 14 | types: 15 | - opened 16 | - reopened 17 | - synchronize 18 | branches: 19 | - master 20 | jobs: 21 | run-tests: 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | fail-fast: false 27 | max-parallel: 5 28 | matrix: 29 | os: [ubuntu-20.04] 30 | python-version: [python3.9, python3.8] 31 | django-version: [django5.0, django4.0, django3.2, django3.1, django3.0] 32 | drf-version: [drf3.14] 33 | name: ${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.drf-version }} 34 | steps: 35 | - name: "Set job environments" 36 | run: | 37 | matrix_python="${{ matrix.python-version }}" 38 | matrix_django="${{ matrix.django-version }}" 39 | matrix_drf="${{ matrix.drf-version }}" 40 | python_v="${matrix_python//[a-zA-Z]}" 41 | django_v="${matrix_django//[a-zA-Z\.]}" 42 | drf_v="${matrix_drf//[a-zA-Z\.]}" 43 | tox_env_name="py${python_v}-django${django_v}-drf${drf_v}" 44 | echo "TOXENV=$tox_env_name" >> $GITHUB_ENV 45 | echo "PYTHON_V=$python_v" >> $GITHUB_ENV 46 | - name: "Installing non-python packages" 47 | run: | 48 | sudo apt-get update -qq 49 | DEBIAN_FRONTEND=noninteractive 50 | echo "Installing build tools" 51 | sudo apt-get install -y build-essential 52 | echo "Installing python Pillow library dependencies" 53 | sudo apt-get install -y libraqm0 libfreetype6-dev libfribidi-dev libimagequant-dev libjpeg-dev liblcms2-dev libopenjp2-7-dev libtiff5-dev libwebp-dev libxcb1-dev 54 | - name: Checkout code (Push) 55 | uses: actions/checkout@v4 56 | if: github.event_name == 'push' 57 | - name: Checkout code (Pull Request) 58 | uses: actions/checkout@v4 59 | if: github.event_name == 'pull_request_target' || github.event_name == 'pull_request' 60 | with: 61 | # Assume PRs are less than 50 commits 62 | ref: ${{ github.event.pull_request.head.ref }} 63 | repository: ${{ github.event.pull_request.head.repo.full_name }} 64 | token: ${{ secrets.GITHUB_TOKEN }} 65 | fetch-depth: 50 66 | - name: Set up Python "${{ env.PYTHON_V }}" 67 | uses: actions/setup-python@v2 68 | with: 69 | python-version: ${{ env.PYTHON_V }} 70 | - name: Install dependencies 71 | run: | 72 | python -m pip install --upgrade pip 73 | python -m pip install --upgrade 'tox>=3,<4' tox-gh-actions testfixtures 74 | - name: Tox tests 75 | run: | 76 | tox -e $TOXENV 77 | - name: Upload coverage 78 | uses: codecov/codecov-action@v1 79 | with: 80 | name: Python ${{ env.PYTHON_V }} 81 | finish-coverage-upload: 82 | needs: run-tests 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Coveralls Finished 86 | uses: coverallsapp/github-action@master 87 | with: 88 | github-token: ${{ secrets.GITHUB_TOKEN }} 89 | parallel-finished: true 90 | publish-to-pypi: 91 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 92 | runs-on: ubuntu-latest 93 | needs: run-tests 94 | steps: 95 | - uses: actions/checkout@master 96 | - name: Set up Python 3.9 97 | uses: actions/setup-python@v1 98 | with: 99 | python-version: 3.9 100 | - name: Install pypa/build 101 | run: >- 102 | python -m 103 | pip install 104 | build 105 | --user 106 | - name: Build a binary wheel and a source tarball 107 | run: >- 108 | python -m 109 | build 110 | --sdist 111 | --wheel 112 | --outdir dist/ 113 | . 114 | - name: Publish distribution 📦 to Test PyPI 115 | uses: pypa/gh-action-pypi-publish@master 116 | if: github.ref == 'refs/heads/master' 117 | continue-on-error: true 118 | with: 119 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 120 | repository_url: https://test.pypi.org/legacy/ 121 | - name: Publish distribution 📦 to PyPI 122 | if: startsWith(github.ref, 'refs/tags') 123 | uses: pypa/gh-action-pypi-publish@master 124 | with: 125 | password: ${{ secrets.PYPI_API_TOKEN }} 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | build/ 4 | dist/ 5 | *.egg-info/ 6 | *.pyc 7 | 8 | # Compiled docs # 9 | ############### 10 | docs/_build/ 11 | 12 | # Packages # 13 | ############ 14 | *.7z 15 | *.dmg 16 | *.gz 17 | *.iso 18 | *.jar 19 | *.rar 20 | *.tar 21 | *.zip 22 | 23 | # Logs and databases # 24 | ###################### 25 | *.log 26 | *.sql 27 | *.sqlite 28 | *.db 29 | 30 | # OS generated files # 31 | ###################### 32 | .DS_Store 33 | 34 | # Coverage Reports # 35 | ################## 36 | .coverage* 37 | htmlcov 38 | htmlcov/* 39 | 40 | # Generated Image # 41 | ################# 42 | __filtered__/ 43 | __filtered__/* 44 | __sized__/ 45 | __sized__/* 46 | 47 | # Pickled Files # 48 | ################# 49 | *.p 50 | 51 | .tox 52 | .idea 53 | -------------------------------------------------------------------------------- /.travis-yml.bak: -------------------------------------------------------------------------------- 1 | # Send this build to the travis.ci container-based infrastructure 2 | # which typically has more capacity than the open-source Linux pool 3 | sudo: false 4 | # Tell Travis you want a Python environment to test in 5 | language: python 6 | # List the versions of Python you'd like to test against 7 | python: 8 | - "3.6" 9 | - "3.7" 10 | - "3.8" 11 | before_install: 12 | - export DJANGO_SETTINGS_MODULE=tests.test_settings 13 | # Tell it the things it will need to install when it boots 14 | install: 15 | # Install the dependencies the app itself has 16 | # which in this case I choose to keep in a requirements file 17 | - pip install tox-travis coverage coveralls 18 | # Tell Travis how to run the test script itself 19 | script: 20 | - tox -r 21 | after_success: 22 | - coveralls 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 WGBH Educational Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include versatileimagefield/static *.css 4 | recursive-include versatileimagefield/static *.js 5 | recursive-include versatileimagefield/templates *.html 6 | recursive-exclude tests * 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | django-versatileimagefield 3 | ========================== 4 | 5 | .. image:: https://github.com/respondcreate/django-versatileimagefield/actions/workflows/tests.yml/badge.svg 6 | :target: https://github.com/respondcreate/django-versatileimagefield/actions/workflows/tests.yml 7 | :alt: Github Actions Status 8 | 9 | .. image:: https://coveralls.io/repos/github/respondcreate/django-versatileimagefield/badge.svg?branch=master 10 | :target: https://coveralls.io/github/respondcreate/django-versatileimagefield?branch=master 11 | :alt: Coverage Status 12 | 13 | .. image:: https://img.shields.io/pypi/v/django-versatileimagefield.svg?style=flat 14 | :target: https://pypi.python.org/pypi/django-versatileimagefield/ 15 | :alt: Latest Version 16 | 17 | ---- 18 | 19 | A drop-in replacement for django's ``ImageField`` that provides a flexible, intuitive and easily-extensible interface for creating new images from the one assigned to the field. 20 | 21 | `Click here for a quick overview `_ of what it is, how it works and whether or not it's the right fit for your project. 22 | 23 | Compatibility 24 | ============= 25 | 26 | - Python: 27 | 28 | - 3.6 29 | - 3.7 30 | - 3.8 31 | - 3.9 32 | 33 | - `Django `_: 34 | 35 | - 3.0.x 36 | - 3.1.x 37 | - 3.2.x 38 | - 4.0.x 39 | - 4.1.x 40 | - 5.0.x 41 | 42 | **NOTE**: The 1.4 release dropped support for Django 1.5.x & 1.6.x. 43 | 44 | **NOTE**: The 1.7 release dropped support for Django 1.7.x. 45 | 46 | **NOTE**: The 2.1 release dropped support for Django 1.9.x. 47 | 48 | **NOTE**: The 3.0 release dropped support for Django 2.x. 49 | 50 | - `Pillow `_ >= 6.2.0 51 | 52 | - `Django REST Framework `_: 53 | 54 | - 3.14.x 55 | 56 | Documentation 57 | ============= 58 | 59 | Full documentation available at `Read the Docs `_. 60 | 61 | Code 62 | ==== 63 | 64 | ``django-versatileimagefield`` is hosted on `github `_. 65 | 66 | Running Tests 67 | ============= 68 | 69 | If you're running tests on Mac OSX you'll need `libmagic` installed. The recommended way to do this is with ``homebrew``: 70 | 71 | .. code-block:: bash 72 | 73 | $ brew install libmagic 74 | 75 | Note: Some systems may also be necessary to install the `non-python Pillow build dependencies `_. 76 | 77 | You'll also need ``tox``: 78 | 79 | .. code-block:: bash 80 | 81 | $ pip install tox 82 | 83 | 84 | To run the entire django-versatileimagefield test matrix, that is, run all tests on all supported combination of versions of ``python``, ``django`` and ``djangorestframework``: 85 | 86 | .. code-block:: bash 87 | 88 | $ tox 89 | 90 | If you just want to run tests against a specific tox environment first, run this command to list all available environments: 91 | 92 | .. code-block:: bash 93 | 94 | $ tox -l 95 | 96 | Then run this command, substituting ``{tox-env}`` with the environment you want to test: 97 | 98 | .. code-block:: bash 99 | 100 | $ tox -e {tox-env} 101 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-versatileimagefield.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-versatileimagefield.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-versatileimagefield" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-versatileimagefield" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .wy-nav-content{max-width:1200px;} 2 | 3 | p.intro-paragraph { 4 | font-size:20px; 5 | line-height:32px; 6 | } 7 | -------------------------------------------------------------------------------- /docs/_static/images/ppoi-adjusted.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/ppoi-adjusted.jpg -------------------------------------------------------------------------------- /docs/_static/images/ppoi-admin-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/ppoi-admin-example.png -------------------------------------------------------------------------------- /docs/_static/images/ppoi-default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/ppoi-default.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess-crop-c0-44__0-22-400x400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess-crop-c0-44__0-22-400x400.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess-crop-c0-5__0-5-400x400.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess-crop-c0-5__0-5-400x400.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess-thumbnail-200x200.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess-thumbnail-200x200.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess__invert__-thumbnail-200x200.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess__invert__-thumbnail-200x200.jpg -------------------------------------------------------------------------------- /docs/_static/images/the-dowager-countess__invert__.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/docs/_static/images/the-dowager-countess__invert__.jpg -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {# layout.html #} 2 | {# Import the theme's layout. #} 3 | {% extends "!layout.html" %} 4 | 5 | {% set css_files = css_files + ['_static/css/custom.css'] %} 6 | 7 | {% block footer %} 8 | {{ super() }} 9 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-versatileimagefield documentation build configuration file, created by 4 | # sphinx-quickstart on Sun May 4 20:11:25 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | #sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.doctest', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.coverage', 36 | 'sphinx.ext.viewcode', 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # The suffix of source filenames. 43 | source_suffix = '.rst' 44 | 45 | # The encoding of source files. 46 | #source_encoding = 'utf-8-sig' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # General information about the project. 52 | project = u'django-versatileimagefield' 53 | copyright = u'2014, Jonathan Ellenberger' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '1.0' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '1.0' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | #language = None 67 | 68 | # There are two options for replacing |today|: either, you set today to some 69 | # non-false value, then it is used: 70 | #today = '' 71 | # Else, today_fmt is used as the format for a strftime call. 72 | #today_fmt = '%B %d, %Y' 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | exclude_patterns = ['_build'] 77 | 78 | # The reST default role (used for this markup: `text`) to use for all 79 | # documents. 80 | #default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | #add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | #add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | #show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = 'sphinx' 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | #modindex_common_prefix = [] 98 | 99 | # If true, keep warnings as "system message" paragraphs in the built documents. 100 | #keep_warnings = False 101 | 102 | 103 | # -- Options for HTML output ---------------------------------------------- 104 | 105 | # The theme to use for HTML and HTML Help pages. See the documentation for 106 | # a list of builtin themes. 107 | on_rtd = os.environ.get('READTHEDOCS', False) 108 | 109 | if not on_rtd: # only import and set the theme if we're building docs locally 110 | import sphinx_rtd_theme 111 | html_theme = 'sphinx_rtd_theme' 112 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 113 | else: 114 | html_theme = 'default' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # Add any extra paths that contain custom files (such as robots.txt or 146 | # .htaccess) here, relative to this directory. These files are copied 147 | # directly to the root of the documentation. 148 | #html_extra_path = [] 149 | 150 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 151 | # using the given strftime format. 152 | #html_last_updated_fmt = '%b %d, %Y' 153 | 154 | # If true, SmartyPants will be used to convert quotes and dashes to 155 | # typographically correct entities. 156 | #html_use_smartypants = True 157 | 158 | # Custom sidebar templates, maps document names to template names. 159 | #html_sidebars = {} 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'django-versatileimagefielddoc' 193 | 194 | 195 | # -- Options for LaTeX output --------------------------------------------- 196 | 197 | latex_elements = { 198 | # The paper size ('letterpaper' or 'a4paper'). 199 | #'papersize': 'letterpaper', 200 | 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | #'pointsize': '10pt', 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | #'preamble': '', 206 | } 207 | 208 | # Grouping the document tree into LaTeX files. List of tuples 209 | # (source start file, target name, title, 210 | # author, documentclass [howto, manual, or own class]). 211 | latex_documents = [ 212 | ('index', 'django-versatileimagefield.tex', u'django-versatileimagefield Documentation', 213 | u'Jonathan Ellenberger', 'manual'), 214 | ] 215 | 216 | # The name of an image file (relative to this directory) to place at the top of 217 | # the title page. 218 | #latex_logo = None 219 | 220 | # For "manual" documents, if this is true, then toplevel headings are parts, 221 | # not chapters. 222 | #latex_use_parts = False 223 | 224 | # If true, show page references after internal links. 225 | #latex_show_pagerefs = False 226 | 227 | # If true, show URL addresses after external links. 228 | #latex_show_urls = False 229 | 230 | # Documents to append as an appendix to all manuals. 231 | #latex_appendices = [] 232 | 233 | # If false, no module index is generated. 234 | #latex_domain_indices = True 235 | 236 | 237 | # -- Options for manual page output --------------------------------------- 238 | 239 | # One entry per manual page. List of tuples 240 | # (source start file, name, description, authors, manual section). 241 | man_pages = [ 242 | ('index', 'django-versatileimagefield', u'django-versatileimagefield Documentation', 243 | [u'Jonathan Ellenberger'], 1) 244 | ] 245 | 246 | # If true, show URL addresses after external links. 247 | #man_show_urls = False 248 | 249 | 250 | # -- Options for Texinfo output ------------------------------------------- 251 | 252 | # Grouping the document tree into Texinfo files. List of tuples 253 | # (source start file, target name, title, author, 254 | # dir menu entry, description, category) 255 | texinfo_documents = [ 256 | ('index', 'django-versatileimagefield', u'django-versatileimagefield Documentation', 257 | u'Jonathan Ellenberger', 'django-versatileimagefield', 'One line description of project.', 258 | 'Miscellaneous'), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | #texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | #texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | #texinfo_show_urls = 'footnote' 269 | 270 | # If true, do not generate a @detailmenu in the "Top" node's menu. 271 | #texinfo_no_detailmenu = False 272 | 273 | 274 | # Example configuration for intersphinx: refer to the Python standard library. 275 | intersphinx_mapping = {'http://docs.python.org/': None} 276 | -------------------------------------------------------------------------------- /docs/deleting_created_images.rst: -------------------------------------------------------------------------------- 1 | Deleting Created Images 2 | ======================= 3 | 4 | .. note:: The deletion API was added in version 1.3 5 | 6 | ``VersatileImageField`` ships with a number of useful methods that make it easy to delete unwanted/stale images and/or clear out their associated cache entries. 7 | 8 | The docs below all reference this example model: 9 | 10 | .. code-block:: python 11 | 12 | # someapp/models.py 13 | 14 | from django.db import models 15 | 16 | from versatileimagefield.fields import VersatileImageField 17 | 18 | class ExampleImageModel(models.Model): 19 | image = VersatileImageField(upload_to='images/') 20 | 21 | .. _deleting-individual-renditions: 22 | 23 | Deleting Individual Renditions 24 | ------------------------------ 25 | 26 | Clearing The Cache 27 | ~~~~~~~~~~~~~~~~~~ 28 | 29 | To delete a cache entry associated with a created image just call its ``clear_cache()`` method: 30 | 31 | .. code-block:: python 32 | :emphasize-lines: 5,8,11 33 | 34 | >>> from someapp.models import ExampleImageModel 35 | >>> instance = ExampleImageModel.objects.get() 36 | # Deletes the cache entry associated with the 400px by 400px 37 | # crop of instance.image 38 | >>> instance.image.crop['400x400'].clear_cache() 39 | # Deletes the cache entry associated with the inverted 40 | # filter of instance.image 41 | >>> instance.image.filters.invert.clear_cache() 42 | # Deletes the cache entry associated with the inverted + cropped-to 43 | # 400px by 400px rendition of instance.image 44 | >>> instance.image.filters.invert.crop['400x400'].clear_cache() 45 | 46 | Deleting An Image 47 | ~~~~~~~~~~~~~~~~~ 48 | 49 | To delete a created image just call its ``delete()`` method: 50 | 51 | .. code-block:: python 52 | :emphasize-lines: 5,8,11 53 | 54 | >>> from someapp.models import ExampleImageModel 55 | >>> instance = ExampleImageModel.objects.get() 56 | # Deletes the image AND cache entry associated with the 400px by 400px 57 | # crop of instance.image 58 | >>> instance.image.crop['400x400'].delete() 59 | # Deletes the image AND cache entry associated with the inverted 60 | # filter of instance.image 61 | >>> instance.image.filters.invert.delete() 62 | # Deletes the image AND cache entry associated with the inverted + 63 | # cropped-to 400px by 400px rendition of instance.image 64 | >>> instance.image.filters.invert.crop['400x400'].delete() 65 | 66 | .. note:: Deleting an image will also clear its associated cache entry. 67 | 68 | .. _deleting-multiple-renditions: 69 | 70 | Deleting Multiple Renditions 71 | ---------------------------- 72 | 73 | Deleting All Sized Images 74 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | 76 | To delete all sized images created by a field use its ``delete_sized_images`` method: 77 | 78 | .. code-block:: python 79 | :emphasize-lines: 4 80 | 81 | >>> from someapp.models import ExampleImageModel 82 | >>> instance = ExampleImageModel.objects.get() 83 | # Deletes all sized images and cache entries associated with instance.image 84 | >>> instance.image.delete_sized_images() 85 | 86 | Deleting All Filtered Images 87 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 88 | 89 | To delete all filtered images created by a field use its ``delete_filtered_images`` method: 90 | 91 | .. code-block:: python 92 | :emphasize-lines: 4 93 | 94 | >>> from someapp.models import ExampleImageModel 95 | >>> instance = ExampleImageModel.objects.get() 96 | # Deletes all filtered images and cache entries associated with instance.image 97 | >>> instance.image.delete_filtered_images() 98 | 99 | Deleting All Filtered + Sized Images 100 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 101 | 102 | To delete all filtered + sized images created by a field use its ``delete_filtered_sized_images`` method: 103 | 104 | .. code-block:: python 105 | :emphasize-lines: 4 106 | 107 | >>> from someapp.models import ExampleImageModel 108 | >>> instance = ExampleImageModel.objects.get() 109 | # Deletes all filtered + sized images and cache entries associated with instance.image 110 | >>> instance.image.delete_filtered_sized_images() 111 | 112 | Deleting ALL Created Images 113 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 114 | 115 | To delete ALL images created by a field (sized, filtered & filtered + sized) use its ``delete_all_created_images`` method: 116 | 117 | .. code-block:: python 118 | :emphasize-lines: 4 119 | 120 | >>> from someapp.models import ExampleImageModel 121 | >>> instance = ExampleImageModel.objects.get() 122 | # Deletes ALL images and cache entries associated with instance.image 123 | >>> instance.image.delete_all_created_images() 124 | 125 | .. note:: The original image (``instance.name`` on ``instance.field.storage`` in the above example) will NOT be deleted. 126 | 127 | .. _automating-rendition-deletion: 128 | 129 | Automating Deletion on ``post_delete`` 130 | -------------------------------------- 131 | 132 | The rendition deleting and cache clearing functionality was written to address the need to delete 'stale' images (i.e. images created from a ``VersatileImageField`` field on a model instance that has since been deleted). Here's a simple example of how to accomplish that with a ``post_delete`` signal receiver: 133 | 134 | .. code-block:: python 135 | :emphasize-lines: 4,11-19 136 | 137 | # someapp/models.py 138 | 139 | from django.db import models 140 | from django.dispatch import receiver 141 | 142 | from versatileimagefield.fields import VersatileImageField 143 | 144 | class ExampleImageModel(models.Model): 145 | image = VersatileImageField(upload_to='images/') 146 | 147 | @receiver(models.signals.post_delete, sender=ExampleImageModel) 148 | def delete_ExampleImageModel_images(sender, instance, **kwargs): 149 | """ 150 | Deletes ExampleImageModel image renditions on post_delete. 151 | """ 152 | # Deletes Image Renditions 153 | instance.image.delete_all_created_images() 154 | # Deletes Original Image 155 | instance.image.delete(save=False) 156 | 157 | .. warning:: There's no undo for deleting images off a storage object so proceed at your own risk! 158 | -------------------------------------------------------------------------------- /docs/drf_integration.rst: -------------------------------------------------------------------------------- 1 | Django REST Framework Integration 2 | ================================= 3 | 4 | If you've got an API powered by `Tom Christie `_'s excellent `Django REST Framework `_ and want to serve images in multiple sizes/renditions ``django-versatileimagefield`` has you covered with its ``VersatileImageFieldSerializer``. 5 | 6 | .. _example-model: 7 | 8 | Example 9 | ------- 10 | 11 | To demonstrate how it works we'll use this simple model: 12 | 13 | .. code-block:: python 14 | 15 | # myproject/person/models.py 16 | 17 | from django.db import models 18 | 19 | from versatileimagefield.fields import VersatileImageField, PPOIField 20 | 21 | 22 | class Person(models.Model): 23 | """Represents a person.""" 24 | name_first = models.CharField('First Name', max_length=80) 25 | name_last = models.CharField('Last Name', max_length=100) 26 | headshot = VersatileImageField( 27 | 'Headshot', 28 | upload_to='headshots/', 29 | ppoi_field='headshot_ppoi' 30 | ) 31 | headshot_ppoi = PPOIField() 32 | 33 | class Meta: 34 | verbose_name = 'Person' 35 | verbose_name_plural = 'People' 36 | 37 | .. _serialization: 38 | 39 | OK, let's write a simple ``ModelSerializer`` subclass to serialize Person instances: 40 | 41 | .. code-block:: python 42 | :emphasize-lines: 5,12-19 43 | 44 | # myproject/person/serializers.py 45 | 46 | from rest_framework import serializers 47 | 48 | from versatileimagefield.serializers import VersatileImageFieldSerializer 49 | 50 | from .models import Person 51 | 52 | 53 | class PersonSerializer(serializers.ModelSerializer): 54 | """Serializes Person instances""" 55 | headshot = VersatileImageFieldSerializer( 56 | sizes=[ 57 | ('full_size', 'url'), 58 | ('thumbnail', 'thumbnail__100x100'), 59 | ('medium_square_crop', 'crop__400x400'), 60 | ('small_square_crop', 'crop__50x50') 61 | ] 62 | ) 63 | 64 | class Meta: 65 | model = Person 66 | fields = ( 67 | 'name_first', 68 | 'name_last', 69 | 'headshot' 70 | ) 71 | 72 | And here's what it would look like serialized: 73 | 74 | .. code-block:: python 75 | :emphasize-lines: 14-19 76 | 77 | >>> from myproject.person.models import Person 78 | >>> john_doe = Person.objects.create( 79 | ... name_first='John', 80 | ... name_last='Doe', 81 | ... headshot='headshots/john_doe_headshot.jpg' 82 | ... ) 83 | >>> john_doe.save() 84 | >>> from myproject.person.serializers import PersonSerializer 85 | >>> john_doe_serialized = PersonSerializer(john_doe) 86 | >>> john_doe_serialized.data 87 | { 88 | 'name_first': 'John', 89 | 'name_last': 'Doe', 90 | 'headshot': { 91 | 'full_size': 'http://api.yoursite.com/media/headshots/john_doe_headshot.jpg', 92 | 'thumbnail': 'http://api.yoursite.com/media/headshots/john_doe_headshot-thumbnail-400x400.jpg', 93 | 'medium_square_crop': 'http://api.yoursite.com/media/headshots/john_doe_headshot-crop-c0-5__0-5-400x400.jpg', 94 | 'small_square_crop': 'http://api.yoursite.com/media/headshots/john_doe_headshot-crop-c0-5__0-5-50x50.jpg', 95 | } 96 | } 97 | 98 | As you can see, the ``sizes`` argument on ``VersatileImageFieldSerializer`` simply unpacks the list of 2-tuples using the value in the first position as the attribute of the image and the second position as a 'Rendition Key' which dictates how the original image should be modified. 99 | 100 | .. _reusing-rendition-key-sets: 101 | 102 | Reusing Rendition Key Sets 103 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 104 | 105 | It's common to want to re-use similar sets of images across models and fields so ``django-versatileimagefield`` provides a setting, ``VERSATILEIMAGEFIELD_RENDITION_KEY_SETS`` for defining them (:ref:`docs `). 106 | 107 | Let's move the Rendition Key Set we used above into our settings file: 108 | 109 | .. code-block:: python 110 | 111 | # myproject/settings.py 112 | 113 | VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { 114 | 'person_headshot': [ 115 | ('full_size', 'url'), 116 | ('thumbnail', 'thumbnail__100x100'), 117 | ('medium_square_crop', 'crop__400x400'), 118 | ('small_square_crop', 'crop__50x50') 119 | ] 120 | } 121 | 122 | Now, let's update our serializer to use it: 123 | 124 | .. code-block:: python 125 | :emphasize-lines: 13 126 | 127 | # myproject/person/serializers.py 128 | 129 | from rest_framework import serializers 130 | 131 | from versatileimagefield.serializers import VersatileImageFieldSerializer 132 | 133 | from .models import Person 134 | 135 | 136 | class PersonSerializer(serializers.ModelSerializer): 137 | """Serializes Person instances""" 138 | headshot = VersatileImageFieldSerializer( 139 | sizes='person_headshot' 140 | ) 141 | 142 | class Meta: 143 | model = Person 144 | fields = ( 145 | 'name_first', 146 | 'name_last', 147 | 'headshot' 148 | ) 149 | 150 | That's it! Now that you know how to define Rendition Key Sets, leverage them to :doc:`improve performance `! 151 | -------------------------------------------------------------------------------- /docs/improving_performance.rst: -------------------------------------------------------------------------------- 1 | Improving Performance 2 | ===================== 3 | 4 | During development, ``VersatileImageField``'s :ref:`on-demand image creation ` enables you to quickly iterate but, once your application is deployed into production, this convenience adds a small bit of overhead that you'll probably want to turn off. 5 | 6 | Turning off on-demand image creation 7 | ------------------------------------ 8 | 9 | To turn off on-demand image creation just set the ``'create_images_on_demand'`` key of the ``VERSATILEIMAGEFIELD_SETTINGS`` setting to ``False`` (:ref:`docs `). Now your ``VersatileImageField`` fields will return URLs to images without first checking to see if they've actually been created yet. 10 | 11 | .. note:: Once an image has been created by a ``VersatileImageField``, a reference to it is stored in the cache which makes for speedy subsequent retrievals. Setting ``VERSATILEIMAGEFIELD_SETTINGS['create_images_on_demand']`` to ``False`` bypasses this entirely making ``VersatileImageField`` perform even faster (:ref:`docs `). 12 | 13 | Ensuring images are created 14 | --------------------------- 15 | 16 | This boost in performance is great but now you'll need to ensure that the images your application links-to actually exist. Luckily, ``VersatileImageFieldWarmer`` will help you do just that. Here's an example in the Python shell using the :ref:`example model ` from the Django REST Framework serialization example: 17 | 18 | .. code-block:: python 19 | 20 | >>> from myproject.person.models import Person 21 | >>> from versatileimagefield.image_warmer import VersatileImageFieldWarmer 22 | >>> person_img_warmer = VersatileImageFieldWarmer( 23 | ... instance_or_queryset=Person.objects.all(), 24 | ... rendition_key_set='person_headshot', 25 | ... image_attr='headshot', 26 | ... verbose=True 27 | ... ) 28 | >>> num_created, failed_to_create = person_img_warmer.warm() 29 | 30 | ``num_created`` will be an integer of how many images were successfully created and ``failed_to_create`` will be a list of paths to images (on the field's storage class) that could not be created (due to a `PIL/Pillow `_ error, for example). 31 | 32 | This technique is useful if you've recently converted your project's ``models.ImageField`` fields to use ``VersatileImageField`` or if you want to 'pre warm' images as part of a `Fabric `_ script. 33 | 34 | .. note:: The above example would create a set of images (as dictated by the ``'person_headshot'`` :ref:`Rendition Key Set `) for the ``headshot`` field of each ``Person`` instance. ``rendition_key_set`` also accepts a valid :ref:`Rendition Key Set ` directly: 35 | 36 | .. code-block:: python 37 | :emphasize-lines: 3-6 38 | 39 | >>> person_img_warmer = VersatileImageFieldWarmer( 40 | ... instance_or_queryset=Person.objects.all(), 41 | ... rendition_key_set=[ 42 | ... ('large_horiz_crop', '1200x600'), 43 | ... ('large_vert_crop', '600x1200'), 44 | ... ], 45 | ... image_attr='headshot', 46 | ... verbose=True 47 | ... ) 48 | 49 | .. note:: Setting ``verbose=True`` when instantiating a ``VersatileImageFieldWarmer`` will display a yum-style progress bar showing the image warming progress: 50 | 51 | .. code-block:: python 52 | 53 | >>> num_created, failed_to_create = person_img_warmer.warm() 54 | [###########----------------------------------------] 20/100 (20%) 55 | 56 | .. note:: The ``image_attr`` argument can be dot-notated in order to follow ``ForeignKey`` and ``OneToOneField`` relationships. Example: ``'related_model.headshot'``. 57 | 58 | Auto-creating sets of images on ``post_save`` 59 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 60 | 61 | You also might want to create new images immediately after model instances are saved. Here's how we'd do it with our example model (see highlighted lines below): 62 | 63 | .. code-block:: python 64 | :emphasize-lines: 4,7,25-33 65 | 66 | # myproject/person/models.py 67 | 68 | from django.db import models 69 | from django.dispatch import receiver 70 | 71 | from versatileimagefield.fields import VersatileImageField, PPOIField 72 | from versatileimagefield.image_warmer import VersatileImageFieldWarmer 73 | 74 | 75 | class Person(models.Model): 76 | """Represents a person.""" 77 | name_first = models.CharField('First Name', max_length=80) 78 | name_last = models.CharField('Last Name', max_length=100) 79 | headshot = VersatileImageField( 80 | 'Headshot', 81 | upload_to='headshots/', 82 | ppoi_field='headshot_ppoi' 83 | ) 84 | headshot_ppoi = PPOIField() 85 | 86 | class Meta: 87 | verbose_name = 'Person' 88 | verbose_name_plural = 'People' 89 | 90 | @receiver(models.signals.post_save, sender=Person) 91 | def warm_Person_headshot_images(sender, instance, **kwargs): 92 | """Ensures Person head shots are created post-save""" 93 | person_img_warmer = VersatileImageFieldWarmer( 94 | instance_or_queryset=instance, 95 | rendition_key_set='person_headshot', 96 | image_attr='headshot' 97 | ) 98 | num_created, failed_to_create = person_img_warmer.warm() 99 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Installation is easy with `pip `__: 5 | 6 | .. code-block:: bash 7 | 8 | $ pip install django-versatileimagefield 9 | 10 | Python Compatibility 11 | -------------------- 12 | 13 | - 3.6.x 14 | - 3.7.x 15 | - 3.8.x 16 | - 3.9.x 17 | 18 | Django Compatibility 19 | -------------------- 20 | 21 | - 3.0.x 22 | - 3.1.x 23 | - 3.2.x 24 | - 4.0.x 25 | - 4.1.x 26 | - 5.0.x 27 | 28 | Dependencies 29 | ------------ 30 | 31 | - ``Pillow``>= 6.2.x 32 | 33 | ``django-versatileimagefield`` depends on the excellent 34 | `Pillow `__ fork of ``PIL``. If you 35 | already have PIL installed, it is recommended you uninstall it prior to 36 | installing ``django-versatileimagefield``: 37 | 38 | .. code-block:: bash 39 | 40 | $ pip uninstall PIL 41 | $ pip install django-versatileimagefield 42 | 43 | .. note:: ``django-versatileimagefield`` will not install ``django``. 44 | 45 | If you are developing on Windows you'll need to install ``python-magic-bin`` as well: 46 | 47 | .. code-block:: bash 48 | 49 | $ pip install python-magic-bin==0.4.1 50 | 51 | .. _settings: 52 | 53 | Settings 54 | -------- 55 | 56 | After installation completes, add ``'versatileimagefield'`` to 57 | ``INSTALLED_APPS``: 58 | 59 | .. code-block:: python 60 | 61 | INSTALLED_APPS = ( 62 | # All your other apps here 63 | 'versatileimagefield', 64 | ) 65 | 66 | .. _versatileimagefield-settings: 67 | 68 | ``VERSATILEIMAGEFIELD_SETTINGS`` 69 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 70 | 71 | A dictionary that allows you to fine-tune how ``django-versatileimagefield`` works: 72 | 73 | .. code-block:: python 74 | 75 | VERSATILEIMAGEFIELD_SETTINGS = { 76 | # The amount of time, in seconds, that references to created images 77 | # should be stored in the cache. Defaults to `2592000` (30 days) 78 | 'cache_length': 2592000, 79 | # The name of the cache you'd like `django-versatileimagefield` to use. 80 | # Defaults to 'versatileimagefield_cache'. If no cache exists with the name 81 | # provided, the 'default' cache will be used instead. 82 | 'cache_name': 'versatileimagefield_cache', 83 | # The save quality of modified JPEG images. More info here: 84 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#jpeg 85 | # Defaults to 70 86 | 'jpeg_resize_quality': 70, 87 | # The name of the top-level folder within storage classes to save all 88 | # sized images. Defaults to '__sized__' 89 | 'sized_directory_name': '__sized__', 90 | # The name of the directory to save all filtered images within. 91 | # Defaults to '__filtered__': 92 | 'filtered_directory_name': '__filtered__', 93 | # The name of the directory to save placeholder images within. 94 | # Defaults to '__placeholder__': 95 | 'placeholder_directory_name': '__placeholder__', 96 | # Whether or not to create new images on-the-fly. Set this to `False` for 97 | # speedy performance but don't forget to 'pre-warm' to ensure they're 98 | # created and available at the appropriate URL. 99 | 'create_images_on_demand': True, 100 | # A dot-notated python path string to a function that processes sized 101 | # image keys. Typically used to md5-ify the 'image key' portion of the 102 | # filename, giving each a uniform length. 103 | # `django-versatileimagefield` ships with two post processors: 104 | # 1. 'versatileimagefield.processors.md5' Returns a full length (32 char) 105 | # md5 hash of `image_key`. 106 | # 2. 'versatileimagefield.processors.md5_16' Returns the first 16 chars 107 | # of the 32 character md5 hash of `image_key`. 108 | # By default, image_keys are unprocessed. To write your own processor, 109 | # just define a function (that can be imported from your project's 110 | # python path) that takes a single argument, `image_key` and returns 111 | # a string. 112 | 'image_key_post_processor': None, 113 | # Whether to create progressive JPEGs. Read more about progressive JPEGs 114 | # here: https://optimus.io/support/progressive-jpeg/ 115 | 'progressive_jpeg': False 116 | } 117 | 118 | .. _placehold-it: 119 | 120 | ``VERSATILEIMAGEFIELD_USE_PLACEHOLDIT`` 121 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 122 | 123 | A boolean that signifies whether optional (``blank=True``) ``VersatileImageField`` fields that do not :ref:`specify a placeholder image ` should return `placehold.it `__ URLs. 124 | 125 | .. _rendition-key-sets: 126 | 127 | ``VERSATILEIMAGEFIELD_RENDITION_KEY_SETS`` 128 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 129 | 130 | A dictionary used to specify 'Rendition Key Sets' that are used for both :doc:`serialization ` or as a way to :doc:`'warm' image files ` so they don't need to be created on demand (i.e. when ``settings.VERSATILEIMAGEFIELD_SETTINGS['create_images_on_demand']`` is set to ``False``) which will greatly improve the overall performance of your app. Here's an example: 131 | 132 | .. code-block:: python 133 | 134 | VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { 135 | 'image_gallery': [ 136 | ('gallery_large', 'crop__800x450'), 137 | ('gallery_square_small', 'crop__50x50') 138 | ], 139 | 'primary_image_detail': [ 140 | ('hero', 'crop__600x283'), 141 | ('social', 'thumbnail__800x800') 142 | ], 143 | 'primary_image_list': [ 144 | ('list', 'crop__400x225'), 145 | ], 146 | 'headshot': [ 147 | ('headshot_small', 'crop__150x175'), 148 | ] 149 | } 150 | 151 | Each key in ``VERSATILEIMAGEFIELD_RENDITION_KEY_SETS`` signifies a 'Rendition Key Set', a list comprised of 2-tuples wherein the first position is a serialization-friendly name of an image rendition and the second position is a 'Rendition Key' (which dictates how the original image should be modified). 152 | 153 | .. _writing-rendition-keys: 154 | 155 | Writing Rendition Keys 156 | ^^^^^^^^^^^^^^^^^^^^^^ 157 | 158 | Rendition Keys are intuitive and easy to write, simply swap in double-underscores for the dot-notated paths you'd use :doc:`in the shell ` or :ref:`in templates `. Examples: 159 | 160 | .. list-table:: 161 | :widths: 15 35 25 25 162 | :header-rows: 1 163 | 164 | * - Intended image 165 | - As 'Rendition Key' 166 | - In the shell 167 | - In templates 168 | * - 400px by 400px Crop 169 | - ``'crop__400x400'`` 170 | - ``instance.image_field.crop['400x400'].url`` 171 | - ``{{ instance.image_field.crop.400x400 }}`` 172 | * - 100px by 100px Thumbnail 173 | - ``'thumbnail__100x100'`` 174 | - ``instance.image_field.thumbnail['100x100'].url`` 175 | - ``{{ instance.image_field.thumbnail.100x100 }}`` 176 | * - Inverted Image (Full Size) 177 | - ``'filters__invert'`` 178 | - ``instance.image_field.filters.invert.url`` 179 | - ``{{ instance.image_field.filters.invert }}`` 180 | * - Inverted Image, 50px by 50px crop 181 | - ``'filters__invert__crop__50x50'`` 182 | - ``instance.image_field.filters.invert.crop['50x50'].url`` 183 | - ``{{ instance.image_field.filters.invert.crop.50x50 }}`` 184 | 185 | Using Rendition Key Sets 186 | ^^^^^^^^^^^^^^^^^^^^^^^^ 187 | 188 | Rendition Key sets are useful! Read up on how they can help you... 189 | 190 | - ... :ref:`serialize VersatileImageField instances ` with Django REST Framework. 191 | - ... :doc:`'pre-warm' images to improve performance `. 192 | -------------------------------------------------------------------------------- /docs/overview.rst: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | You're probably using an `ImageField `_. 5 | 6 | .. code-block:: python 7 | 8 | from django.db import models 9 | 10 | class ExampleModel(models.Model): 11 | image = models.ImageField( 12 | 'Image', 13 | upload_to='images/' 14 | ) 15 | 16 | You should :doc:`swap it out` for a ``VersatileImageField``. It's better! 17 | 18 | .. code-block:: python 19 | :emphasize-lines: 3,6 20 | 21 | from django.db import models 22 | 23 | from versatileimagefield.fields import VersatileImageField 24 | 25 | class ExampleModel(models.Model): 26 | image = VersatileImageField( 27 | 'Image', 28 | upload_to='images/' 29 | ) 30 | 31 | Works just like ``ImageField`` 32 | ------------------------------ 33 | 34 | Out-of-the-box, ``VersatileImageField`` provides the same functionality as ``ImageField``: 35 | 36 | .. list-table:: 37 | :header-rows: 1 38 | 39 | * - Template Code 40 | - Image 41 | * - ```` 42 | - .. figure:: /_static/images/the-dowager-countess.jpg 43 | :alt: An Image 44 | 45 | 46 | *So what sets it apart?* 47 | 48 | Create Images Wherever You Need Them 49 | ------------------------------------ 50 | 51 | A ``VersatileImageField`` can create new images on-demand **both in templates and the shell**. 52 | 53 | Let's make a thumbnail image that would fit within a 200px by 200px area: 54 | 55 | .. list-table:: 56 | :header-rows: 1 57 | 58 | * - Template Code 59 | - Image 60 | * - ```` 61 | - .. figure:: /_static/images/the-dowager-countess-thumbnail-200x200.jpg 62 | :alt: An Thumbnail Image 63 | 64 | No crufty templatetags necessary! Here's how you'd do the same in the shell: 65 | 66 | .. code-block:: python 67 | :emphasize-lines: 3 68 | 69 | >>> from someapp.models import ExampleModel 70 | >>> instance = ExampleModel.objects.all()[0] 71 | >>> instance.image.thumbnail['200x200'].url 72 | '/media/__sized__/images/test-image-thumbnail-200x200.jpg' 73 | >>> instance.image.thumbnail['200x200'].name 74 | '__sized__/images/test-image-thumbnail-200x200.jpg' 75 | 76 | Crop images at specific sizes 77 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | You can use it to create cropped images, too: 80 | 81 | .. list-table:: 82 | :header-rows: 1 83 | 84 | * - Template Code 85 | - Default, Absolutely Centered Crop 86 | * - ```` 87 | - .. figure:: /_static/images/the-dowager-countess-crop-c0-5__0-5-400x400.jpg 88 | :alt: Absolute Center Crop 89 | 90 | *Uh-oh. That looks weird.* 91 | 92 | Custom, Per-Image Cropping 93 | -------------------------- 94 | 95 | Don't worry! ``VersatileImageField`` ships with a handy admin-compatible widget that you can use to specify an image's :doc:`Primary Point of Interest (PPOI)` by clicking on it. 96 | 97 | *Note the translucent red square underneath the mouse cursor in the image within the left column below:* 98 | 99 | .. list-table:: 100 | :header-rows: 1 101 | 102 | * - Admin Widget PPOI Selection Tool 103 | - Resultant Cropped Image 104 | * - .. figure:: /_static/images/ppoi-adjusted.jpg 105 | :alt: Centered PPOI 106 | - .. figure:: /_static/images/the-dowager-countess-crop-c0-44__0-22-400x400.jpg 107 | :alt: Custom PPOI Entered 108 | 109 | *Ahhhhh, that's better.* 110 | 111 | Filters, too! 112 | ------------- 113 | 114 | ``VersatileImageField`` has :ref:`filters `, too! Let's create an inverted image: 115 | 116 | .. list-table:: 117 | :header-rows: 1 118 | 119 | * - Template Code 120 | - Image 121 | * - ```` 122 | - .. figure:: /_static/images/the-dowager-countess__invert__.jpg 123 | :alt: Inverted Image 124 | 125 | You can chain filters and sizers together: 126 | 127 | .. list-table:: 128 | :header-rows: 1 129 | 130 | * - Template Code 131 | - Image 132 | * - ```` 133 | - .. figure:: /_static/images/the-dowager-countess__invert__-thumbnail-200x200.jpg 134 | :alt: Inverted Thumbnail Image 135 | 136 | Write your own Sizers & Filters 137 | ------------------------------- 138 | 139 | Making new sizers and filters (or overriding existing ones) is super-easy via the :doc:`Sizer and Filter framework `. 140 | 141 | Django REST Framework Integration 142 | --------------------------------- 143 | 144 | If you've got an API powered by `Django REST Framework `_ you can use ``VersatileImageField`` to serve multiple images (in any number of sizes and renditions) from a single field. :doc:`Learn more here `. 145 | 146 | Flexible in development, light-weight in production 147 | --------------------------------------------------- 148 | 149 | ``VersatileImageField``'s on-demand image creation provides maximum flexibility during development but can be :doc:`easily turned off ` so your app performs like a champ in production. 150 | 151 | Fully Tested & Python 3 Ready 152 | ----------------------------- 153 | 154 | ``django-versatileimagefield`` is a rock solid, `fully-tested `_ Django app that is compatible with Python 3.6 thru 3.9 and works with Django 3.0.x thru 4.1.x 155 | 156 | Get Started 157 | ----------- 158 | 159 | You should totally :doc:`try it out `! It's 100% backwards compatible with ``ImageField`` so you've got nothing to lose! 160 | -------------------------------------------------------------------------------- /docs/specifying_ppoi.rst: -------------------------------------------------------------------------------- 1 | ============================================= 2 | Specifying a Primary Point of Interest (PPOI) 3 | ============================================= 4 | 5 | The :ref:`crop Sizer` is super-useful for creating images at a specific 6 | size/aspect-ratio however, sometimes you want the 'crop centerpoint' to 7 | be somewhere other than the center of a particular image. In fact, the 8 | initial inspiration for ``django-versatileimagefield`` came as a result 9 | of tackling this very problem. 10 | 11 | The ``crop`` Sizer's core functionality (located in the ``versatileimagefield.versatileimagefield.CroppedImage.crop_on_centerpoint`` method) was inspired by PIL's 12 | `ImageOps.fit `__ 13 | function (by `Kevin Cazabon `__) which takes an optional 14 | keyword argument, ``centering``, that expects a 2-tuple comprised of 15 | floats which are greater than or equal to 0 and less than or equal to 1. These two values 16 | together form a cartesian coordinate system which dictates the percentage of pixels to 'trim' off each of the long sides (i.e. left/right or top/bottom, depending on the aspect ratio of the cropped size vs. the original size): 17 | 18 | +----------+--------------+--------------+--------------+ 19 | | |Left |Center |Right | 20 | +==========+==============+==============+==============+ 21 | |**Top** |``(0.0, 0.0)``|``(0.0, 0.5)``|``(0.0, 1.0)``| 22 | +----------+--------------+--------------+--------------+ 23 | |**Middle**|``(0.5, 0.0)``|``(0.5, 0.5)``|``(0.5, 1.0)``| 24 | +----------+--------------+--------------+--------------+ 25 | |**Bottom**|``(1.0, 0.0)``|``(1.0, 0.5)``|``(1.0, 1.0)``| 26 | +----------+--------------+--------------+--------------+ 27 | 28 | The ``crop`` Sizer works in a similar way but converts the 2-tuple into an exact (x, y) pixel coordinate which is then used as the 'centerpoint' of the crop. This approach gives significantly more accurate results than using ``ImageOps.fit``, especially when dealing with PPOI values located near the edges of an image *or* aspect ratios that differ significantly from the original image. 29 | 30 | .. note:: Even though the PPOI value is used as a crop 'centerpoint', the pixel it corresponds to won't necessarily be in the center of the cropped image, especially if its near the edges of the original image. 31 | 32 | .. note:: At present, only the ``crop`` Sizer changes how it creates images 33 | based on PPOI but a ``VersatileImageField`` makes its PPOI value 34 | available to ALL its attached Filters and Sizers. Get creative! 35 | 36 | The PPOIField 37 | ============= 38 | 39 | Each image managed by a ``VersatileImageField`` can store its own, 40 | unique PPOI in the database via the easy-to-use ``PPOIField``. Here's 41 | how to integrate it into our example model (relevant lines highlighted in the code block below): 42 | 43 | .. code-block:: python 44 | :emphasize-lines: 4,5,17,29,30,31 45 | 46 | # models.py with `VersatileImageField` & `PPOIField` 47 | from django.db import models 48 | 49 | from versatileimagefield.fields import VersatileImageField, \ 50 | PPOIField 51 | 52 | class ImageExampleModel(models.Model): 53 | name = models.CharField( 54 | 'Name', 55 | max_length=80 56 | ) 57 | image = VersatileImageField( 58 | 'Image', 59 | upload_to='images/testimagemodel/', 60 | width_field='width', 61 | height_field='height', 62 | ppoi_field='ppoi' 63 | ) 64 | height = models.PositiveIntegerField( 65 | 'Image Height', 66 | blank=True, 67 | null=True 68 | ) 69 | width = models.PositiveIntegerField( 70 | 'Image Width', 71 | blank=True, 72 | null=True 73 | ) 74 | ppoi = PPOIField( 75 | 'Image PPOI' 76 | ) 77 | 78 | class Meta: 79 | verbose_name = 'Image Example' 80 | verbose_name_plural = 'Image Examples' 81 | 82 | As you can see, you'll need to add a new ``PPOIField`` field to your 83 | model and then include the name of that field in the 84 | ``VersatileImageField``'s ``ppoi_field`` keyword argument. That's it! 85 | 86 | .. note:: ``PPOIField`` is fully-compatible with 87 | `south `_ so 88 | migrate to your heart's content! 89 | 90 | How PPOI is Stored in the Database 91 | ---------------------------------- 92 | 93 | The **Primary Point of Interest** is stored in the database as a string 94 | with the x and y coordinates limited to two decimal places and separated 95 | by an 'x' (for instance: ``'0.5x0.5'`` or ``'0.62x0.28'``). 96 | 97 | Setting PPOI 98 | ============ 99 | 100 | PPOI is set via the ``ppoi`` attribute on a ``VersatileImageField``.. 101 | 102 | When you save a model instance, ``VersatileImageField`` will ensure its 103 | currently-assigned PPOI value is 'sent' to the ``PPOIField`` associated 104 | with it (if any) prior to writing to the database. 105 | 106 | Via The Shell 107 | ------------- 108 | 109 | .. code-block:: python 110 | 111 | # Importing our example Model 112 | >>> from someapp.models import ImageExampleModel 113 | # Retrieving a model instance 114 | >>> example = ImageExampleModel.objects.all()[0] 115 | # Retrieving the current PPOI value associated with the image field 116 | # A `VersatileImageField`'s PPOI value is ALWAYS associated with the `ppoi` 117 | # attribute, irregardless of what you named the `PPOIField` attribute on your model 118 | >>> example.image.ppoi 119 | (0.5, 0.5) 120 | # Creating a cropped image 121 | >>> example.image.crop['400x400'].url 122 | u'/media/__sized__/images/testimagemodel/test-image-crop-c0-5__0-5-400x400.jpg' 123 | # Changing the PPOI value 124 | >>> example.image.ppoi = (1, 1) 125 | # Creating a new cropped image with the new PPOI value 126 | >>> example.image.crop['400x400'].url 127 | u'/media/__sized__/images/testimagemodel/test-image-crop-c1__1-400x400.jpg' 128 | # PPOI values can be set as either a tuple or a string 129 | >>> example.image.ppoi = '0.1x0.55' 130 | >>> example.image.ppoi 131 | (0.1, 0.55) 132 | >>> example.image.ppoi = (0.75, 0.25) 133 | >>> example.image.crop['400x400'].url 134 | u'/media/__sized__/images/testimagemodel/test-image-crop-c0-75__0-25-400x400.jpg' 135 | # u'0.75x0.25' is written to the database in the 'ppoi' column associated with 136 | # our example model 137 | >>> example.save() 138 | 139 | As you can see, changing an image's PPOI changes the filename of the 140 | cropped image. This ensures updates to a ``VersatileImageField``'s PPOI 141 | value will result in unique cache entries for each unique image it 142 | creates. 143 | 144 | .. note:: Each time a field's PPOI is set, its attached Filters & Sizers will 145 | be immediately updated with the new value. 146 | 147 | .. _ppoi-formfield: 148 | 149 | FormField/Admin Integration 150 | ================================ 151 | 152 | It's pretty hard to accurately set a particular image's PPOI when 153 | working in the Python shell so ``django-versatileimagefield`` ships with 154 | an admin-ready formfield. Simply add an image, click 'Save and continue 155 | editing', click where you'd like the PPOI to be and then save your model 156 | instance again. A helpful translucent red square will indicate where the 157 | PPOI value is currently set to on the image: 158 | 159 | .. figure:: /_static/images/ppoi-admin-example.png 160 | :alt: django-versatileimagefield PPOI admin widget example 161 | 162 | django-versatileimagefield PPOI admin widget example 163 | 164 | .. note:: ``PPOIField`` is not editable so it will be automatically excluded from the admin. 165 | 166 | .. _django-15-admin-note: 167 | 168 | Django 1.5 Admin Integration for required ``VersatileImageField`` fields 169 | ------------------------------------------------------------------------ 170 | 171 | If you're using a required (i.e. ``blank=False``) ``VersatileImageField`` on a project running Django 1.5 you'll need a custom form class to circumvent an already-fixed-in-Django-1.6 issue (that has to do with required fields associated with a ``MultiValueField``/``MultiWidget`` used in a ``ModelForm``). 172 | 173 | The example below uses an example model ``YourModel`` that has a required ``VersatileImageField`` as the ``image`` attribute. 174 | 175 | .. code-block:: python 176 | :emphasize-lines: 5,10 177 | 178 | # yourapp/forms.py 179 | 180 | from django.forms import ModelForm 181 | 182 | from versatileimagefield.fields import SizedImageCenterpointClickDjangoAdminField 183 | 184 | from .models import YourModel 185 | 186 | class YourModelForm(VersatileImageTestModelForm): 187 | image = SizedImageCenterpointClickDjangoAdminField(required=False) 188 | 189 | class Meta: 190 | model = YourModel 191 | fields = ('image',) 192 | 193 | Note the ``required=False`` in the formfield definition in the above example. 194 | 195 | Integrating the custom form into the admin: 196 | 197 | .. code-block:: python 198 | :emphasize-lines: 6,9 199 | 200 | # yourapp/admin.py 201 | 202 | from django.contrib import admin 203 | 204 | from .forms import YourModelForm 205 | from .models import YourModel 206 | 207 | class YourModelAdmin(admin.ModelAdmin): 208 | form = YourModelForm 209 | 210 | admin.site.register(YourModel, YourModelAdmin) 211 | -------------------------------------------------------------------------------- /docs/using_sizers_and_filters.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Using Sizers and Filters 3 | ======================== 4 | 5 | Where ``VersatileImageField`` shines is in its ability to create new 6 | images on the fly via its Sizer & Filter framework. 7 | 8 | Sizers 9 | ====== 10 | 11 | Sizers provide a way to create new images of differing 12 | sizes from the one assigned to the field. ``VersatileImageField`` ships 13 | with two Sizers, ``thumbnail`` and ``crop``. 14 | 15 | Each Sizer registered to the :ref:`Sizer registry ` is available as an attribute 16 | on each ``VersatileImageField``. Sizers are ``dict`` subclasses that 17 | only accept precisely formatted keys comprised of two integers – 18 | representing width and height, respectively – separated by an 'x' (i.e. 19 | ``['400x400']``). If you send a malformed/invalid key to a Sizer, a 20 | ``MalformedSizedImageKey`` exception will raise. 21 | 22 | Included Sizers 23 | --------------- 24 | 25 | thumbnail 26 | ^^^^^^^^^ 27 | 28 | Here's how you would create a thumbnail image that would be constrained 29 | to fit within a 400px by 400px area: 30 | 31 | .. code-block:: python 32 | :emphasize-lines: 6,7,10,11 33 | 34 | # Importing our example Model 35 | >>> from someapp.models import ImageExampleModel 36 | # Retrieving a model instance 37 | >>> example = ImageExampleModel.objects.all()[0] 38 | # Displaying the path-on-storage of the image currently assigned to the field 39 | >>> example.image.name 40 | u'images/testimagemodel/test-image.jpg' 41 | # Retrieving the path on the field's storage class to a 400px wide 42 | # by 400px tall constrained thumbnail of the image. 43 | >>> example.image.thumbnail['400x400'].name 44 | u'__sized__/images/testimagemodel/test-image-thumbnail-400x400.jpg' 45 | # Retrieving the URL to the 400px wide by 400px tall thumbnail 46 | >>> example.image.thumbnail['400x400'].url 47 | u'/media/__sized__/images/testimagemodel/test-image-thumbnail-400x400.jpg' 48 | 49 | .. _on-demand-image-creation: 50 | 51 | .. note:: Images are created on-demand. If no image had yet existed at the location required – by either the path (``.name``) *or* URL (``.url``) shown in the highlighted lines above – one would have been created directly before returning it. 52 | 53 | Here's how you'd open the thumbnail image we just created as an image file 54 | directly in the shell: 55 | 56 | .. code-block:: python 57 | 58 | >>> thumbnail_image = example.image.field.storage.open( 59 | ... example.image.thumbnail['400x400'].name 60 | ... ) 61 | 62 | .. _crop-sizer: 63 | 64 | crop 65 | ^^^^ 66 | 67 | To create images cropped to a specific size, use the ``crop`` Sizer: 68 | 69 | .. code-block:: python 70 | 71 | # Retrieving the URL to a 400px wide by 400px tall crop of the image 72 | >>> example.image.crop['400x400'].url 73 | u'/media/__sized__/images/testimagemodel/test-image-crop-c0-5__0-5-400x400.jpg' 74 | 75 | The ``crop`` Sizer will first scale an image down to its longest side 76 | and then crop/trim inwards, centered on the **Primary Point of 77 | Interest** (PPOI, for short). For more info about what PPOI is and how 78 | it's used see the :doc:`Specifying a Primary Point of Interest 79 | (PPOI) ` section. 80 | 81 | How Sized Image Files are Named/Stored 82 | '''''''''''''''''''''''''''''''''''''' 83 | 84 | All Sizers subclass from 85 | ``versatileimagefield.datastructures.sizedimage.SizedImage`` which uses 86 | a unique-to-size-specified string – provided via its 87 | ``get_filename_key()`` method – that is included in the filename of each 88 | image it creates. 89 | 90 | .. note:: The ``thumbnail`` Sizer simply combines ``'thumbnail'`` with the 91 | size key passed (i.e. ``'400x400'`` ) while the ``crop`` Sizer 92 | combines ``'crop'``, the field's PPOI value (as a string) and the 93 | size key passed; all Sizer 'filename keys' begin and end with dashes 94 | ``'-'`` for readability. 95 | 96 | All images created by a Sizer are stored within the field's ``storage`` 97 | class in a top-level folder named ``'__sized__'``, maintaining the same 98 | descendant folder structure as the original image. If you'd like to 99 | change the name of this folder to something other than ``'__sized__'``, 100 | adjust the value of 101 | ``VERSATILEIMAGEFIELD_SETTINGS['sized_directory_name']`` within your 102 | settings file. 103 | 104 | Sizers are quick and easy to write, for more information about how it's 105 | done, see the :ref:`Writing a Custom Sizer ` 106 | section. 107 | 108 | .. _filters: 109 | 110 | Filters 111 | ======= 112 | 113 | Filters create new images that are the same size and aspect ratio as the 114 | original image. 115 | 116 | Included Filters 117 | ---------------- 118 | 119 | invert 120 | ^^^^^^ 121 | 122 | The ``invert`` filter will invert the color palette of an image: 123 | 124 | .. code-block:: python 125 | 126 | # Importing our example Model 127 | >>> from someapp.models import ImageExampleModel 128 | # Retrieving a model instance 129 | >>> example = ImageExampleModel.objects.all()[0] 130 | # Returning the path-on-storage to the image currently assigned to the field 131 | >>> example.image.name 132 | u'images/testimagemodel/test-image.jpg' 133 | # Displaying the path (within the field's storage class) to an image 134 | # with an inverted color pallete from that of the original image 135 | >>> example.image.filters.invert.name 136 | u'images/testimagemodel/__filtered__/test-image__invert__.jpg' 137 | # Displaying the URL to the inverted image 138 | >>> example.image.filters.invert.url 139 | u'/media/images/testimagemodel/__filtered__/test-image__invert__.jpg' 140 | 141 | As you can see, there's a ``filters`` attribute available on each 142 | ``VersatileImageField`` which contains all filters currently registered 143 | to the Filter registry. 144 | 145 | .. _using-sizers-with-filters: 146 | 147 | Using Sizers with Filters 148 | ------------------------- 149 | 150 | What makes Filters extra-useful is that they have access to all 151 | registered Sizers: 152 | 153 | .. code-block:: python 154 | 155 | # Creating a thumbnail of a filtered image 156 | >>> example.image.filters.invert.thumbnail['400x400'].url 157 | u'/media/__sized__/images/testimagemodel/__filtered__/test-image__invert__-thumbnail-400x400.jpg' 158 | # Creating a crop from a filtered image 159 | >>> example.image.filters.invert.crop['400x400'].url 160 | u'/media/__sized__/images/testimagemodel/__filtered__/test-image__invert__-c0-5__0-5-400x400.jpg' 161 | 162 | .. note:: Filtered images are created the first time they are directly 163 | accessed (by either evaluating their ``name``/``url`` attributes or 164 | by accessing a Sizer attached to it). Once created, a reference is 165 | stored in the cache for each created image which makes for speedy 166 | subsequent retrievals. 167 | 168 | How Filtered Image Files are Named/Stored 169 | ----------------------------------------- 170 | 171 | All Filters subclass from 172 | ``versatileimagefield.datastructures.filteredimage.FilteredImage`` which 173 | provides a ``get_filename_key()`` method that returns a 174 | unique-to-filter-specified string – surrounded by double underscores, 175 | i.e. ``'__invert__'`` – which is appended to the filename of each image 176 | it creates. 177 | 178 | All images created by a Filter are stored within a folder named 179 | ``__filtered__`` that sits in the same directory as the original image. 180 | If you'd like to change the name of this folder to something other than 181 | '**filtered**\ ', adjust the value of 182 | ``VERSATILEIMAGEFIELD_SETTINGS['filtered_directory_name']`` within your 183 | settings file. 184 | 185 | Filters are quick and easy to write, for more information about creating 186 | your own, see the :ref:`Writing a Custom Filter ` 187 | section. 188 | 189 | .. _template-usage: 190 | 191 | Using Sizers / Filters in Templates 192 | =================================== 193 | 194 | Template usage is straight forward and easy since both attributes and 195 | dictionary keys can be accessed via dot-notation; no crufty templatetags 196 | necessary: 197 | 198 | .. code-block:: html 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | .. note:: Using the ``url`` attribute on Sizers is optional in templates. Why? 212 | All Sizers return an instance of 213 | ``versatileimagefield.datastructures.sizedimage.SizedImageInstance`` 214 | which provides the sized image's URL via the ``__unicode__()`` 215 | method (which django's templating engine looks for when asked 216 | to render class instances directly). 217 | -------------------------------------------------------------------------------- /post_processor_runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import django 5 | from django.conf import settings 6 | 7 | sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) 8 | 9 | if __name__ == "__main__": 10 | # Run Main Tests 11 | from django.test.utils import get_runner 12 | # Run post-processor tests 13 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.post_processor.test_settings' 14 | django.setup() 15 | TestRunnerPostProcessor = get_runner( 16 | settings, 17 | 'tests.post_processor.discover_tests.DiscoverPostProcessorRunner' 18 | ) 19 | test_runner_post_processor = TestRunnerPostProcessor() 20 | failures = test_runner_post_processor.run_tests(["tests.post_processor"]) 21 | sys.exit(bool(failures)) 22 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | import django 5 | from django.conf import settings 6 | 7 | sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) 8 | 9 | if __name__ == "__main__": 10 | # Run Main Tests 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 12 | django.setup() 13 | from django.test.utils import get_runner 14 | TestRunner = get_runner(settings) 15 | test_runner = TestRunner() 16 | failures = test_runner.run_tests(["tests"]) 17 | sys.exit(bool(failures)) 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E305 3 | exclude=.git,./docs/,./tests/test_*.py,./venv/, 4 | max-line-length = 119 5 | 6 | [metadata] 7 | license-file = LICENSE 8 | 9 | [bdist_wheel] 10 | universal = 1 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from distutils.core import setup 3 | from setuptools import find_packages 4 | 5 | setup( 6 | name='django-versatileimagefield', 7 | packages=find_packages(), 8 | version='3.1', 9 | author='Jonathan Ellenberger', 10 | author_email='jonathan_ellenberger@wgbh.org', 11 | url='http://github.com/respondcreate/django-versatileimagefield/', 12 | license='MIT License, see LICENSE', 13 | description="A drop-in replacement for django's ImageField that provides " 14 | "a flexible, intuitive and easily-extensible interface for " 15 | "creating new images from the one assigned to the field.", 16 | long_description=open('README.rst').read(), 17 | zip_safe=False, 18 | install_requires=[ 19 | 'Pillow>=6.2.0', 20 | 'python-magic>=0.4.22,<1.0.0', 21 | 'Django>=3.0', 22 | ], 23 | include_package_data=True, 24 | keywords=[ 25 | 'django', 26 | ], 27 | classifiers=[ 28 | 'Development Status :: 5 - Production/Stable', 29 | 'Environment :: Web Environment', 30 | 'Framework :: Django', 31 | 'Framework :: Django :: 3.0', 32 | 'Framework :: Django :: 3.1', 33 | 'Framework :: Django :: 3.2', 34 | 'Framework :: Django :: 4.0', 35 | 'Framework :: Django :: 4.1', 36 | 'Framework :: Django :: 5.0', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: MIT License', 39 | 'Natural Language :: English', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python', 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.6', 44 | 'Programming Language :: Python :: 3.7', 45 | 'Programming Language :: Python :: 3.8', 46 | 'Programming Language :: Python :: 3.9', 47 | 'Topic :: Multimedia :: Graphics :: Presentation', 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /test_reqs.txt: -------------------------------------------------------------------------------- 1 | Django==2.2.8 2 | Pillow>=6.2.0 3 | Sphinx==1.2.3 4 | coverage==5.5 5 | coveralls==3.0.1 6 | djangorestframework==3.10.3 7 | flake8==2.2.5 8 | python-magic==0.4.22 9 | requests==2.5.1 10 | sphinx-rtd-theme==0.1.6 11 | testfixtures==6.17.1 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.forms import ModelForm 3 | 4 | from versatileimagefield.widgets import ClearableFileInputWithImagePreview, VersatileImagePPOISelectWidget 5 | 6 | from .models import VersatileImageTestModel, VersatileImageWidgetTestModel 7 | 8 | 9 | class VersatileImageTestModelForm(ModelForm): 10 | 11 | class Meta: 12 | model = VersatileImageTestModel 13 | fields = ( 14 | 'image', 15 | 'img_type', 16 | 'optional_image', 17 | 'optional_image_2', 18 | 'optional_image_3' 19 | ) 20 | widgets = { 21 | 'optional_image': VersatileImagePPOISelectWidget(), 22 | } 23 | 24 | 25 | class VersatileImageWidgetTestModelForm(ModelForm): 26 | 27 | class Meta: 28 | model = VersatileImageWidgetTestModel 29 | fields = '__all__' 30 | widgets = { 31 | 'optional_image_2': ClearableFileInputWithImagePreview(), 32 | } 33 | 34 | 35 | class VersatileImageTestModelAdmin(admin.ModelAdmin): 36 | form = VersatileImageTestModelForm 37 | 38 | 39 | class VersatileImageWidgetTestModelAdmin(admin.ModelAdmin): 40 | form = VersatileImageWidgetTestModelForm 41 | 42 | admin.site.register(VersatileImageTestModel, VersatileImageTestModelAdmin) 43 | admin.site.register(VersatileImageWidgetTestModel, VersatileImageWidgetTestModelAdmin) 44 | -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm 2 | 3 | from versatileimagefield.forms import ( 4 | SizedImageCenterpointClickDjangoAdminField, SizedImageCenterpointClickBootstrap3Field 5 | ) 6 | 7 | from .models import VersatileImageTestModel, VersatileImageWidgetTestModel 8 | 9 | 10 | class VersatileImageTestModelForm(ModelForm): 11 | """A form for testing VersatileImageFields.""" 12 | 13 | image = SizedImageCenterpointClickDjangoAdminField() 14 | optional_image = SizedImageCenterpointClickBootstrap3Field() 15 | 16 | class Meta: 17 | model = VersatileImageTestModel 18 | fields = ( 19 | 'img_type', 20 | 'image', 21 | 'optional_image' 22 | ) 23 | 24 | 25 | class VersatileImageWidgetTestModelForm(ModelForm): 26 | """A form for testing VersatileImageField widgets.""" 27 | 28 | class Meta: 29 | model = VersatileImageWidgetTestModel 30 | fields = ('optional_image_with_ppoi',) 31 | -------------------------------------------------------------------------------- /tests/media/cmyk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/cmyk.jpg -------------------------------------------------------------------------------- /tests/media/delete-test/python-logo-delete-test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/delete-test/python-logo-delete-test.jpg -------------------------------------------------------------------------------- /tests/media/exif-orientation-examples/Landscape_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/exif-orientation-examples/Landscape_3.jpg -------------------------------------------------------------------------------- /tests/media/exif-orientation-examples/Landscape_6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/exif-orientation-examples/Landscape_6.jpg -------------------------------------------------------------------------------- /tests/media/exif-orientation-examples/Landscape_8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/exif-orientation-examples/Landscape_8.jpg -------------------------------------------------------------------------------- /tests/media/foo/python-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/foo/python-logo.jpg -------------------------------------------------------------------------------- /tests/media/on-storage-placeholder/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/on-storage-placeholder/placeholder.png -------------------------------------------------------------------------------- /tests/media/python-logo-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo-2.jpg -------------------------------------------------------------------------------- /tests/media/python-logo-no-ext: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo-no-ext -------------------------------------------------------------------------------- /tests/media/python-logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo.gif -------------------------------------------------------------------------------- /tests/media/python-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo.jpg -------------------------------------------------------------------------------- /tests/media/python-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo.png -------------------------------------------------------------------------------- /tests/media/python-logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/python-logo.webp -------------------------------------------------------------------------------- /tests/media/transparent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/transparent.gif -------------------------------------------------------------------------------- /tests/media/verify-against/exif-orientation-examples/Landscape_3-thumbnail-100x100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/exif-orientation-examples/Landscape_3-thumbnail-100x100.jpg -------------------------------------------------------------------------------- /tests/media/verify-against/exif-orientation-examples/Landscape_6-thumbnail-100x100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/exif-orientation-examples/Landscape_6-thumbnail-100x100.jpg -------------------------------------------------------------------------------- /tests/media/verify-against/exif-orientation-examples/Landscape_8-thumbnail-100x100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/exif-orientation-examples/Landscape_8-thumbnail-100x100.jpg -------------------------------------------------------------------------------- /tests/media/verify-against/python-logo-crop-c0__0-100x30.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/python-logo-crop-c0__0-100x30.gif -------------------------------------------------------------------------------- /tests/media/verify-against/python-logo-crop-c0__0-30x100.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/python-logo-crop-c0__0-30x100.gif -------------------------------------------------------------------------------- /tests/media/verify-against/python-logo-crop-c1__1-100x30.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/python-logo-crop-c1__1-100x30.gif -------------------------------------------------------------------------------- /tests/media/verify-against/python-logo-crop-c1__1-30x100.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/media/verify-against/python-logo-crop-c1__1-30x100.gif -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.db import models 4 | 5 | from versatileimagefield.fields import VersatileImageField, PPOIField 6 | from versatileimagefield.placeholder import OnDiscPlaceholderImage, OnStoragePlaceholderImage 7 | 8 | 9 | class MaybeVersatileImageModel(models.Model): 10 | name = models.CharField(max_length=30) 11 | image = VersatileImageField(upload_to='./', blank=True, null=True) 12 | 13 | 14 | class VersatileImageTestModel(models.Model): 15 | """A model for testing VersatileImageFields.""" 16 | 17 | img_type = models.CharField(max_length=5, unique=True) 18 | image = VersatileImageField( 19 | upload_to='./', 20 | ppoi_field='ppoi', 21 | width_field='width', 22 | height_field='height' 23 | ) 24 | height = models.PositiveIntegerField('Image Height', blank=True, null=True) 25 | width = models.PositiveIntegerField('Image Width', blank=True, null=True) 26 | optional_image = VersatileImageField( 27 | upload_to='./', 28 | blank=True, 29 | placeholder_image=OnDiscPlaceholderImage( 30 | path=os.path.join( 31 | os.path.dirname(os.path.abspath(__file__)), 32 | 'placeholder.png' 33 | ) 34 | ) 35 | ) 36 | optional_image_2 = VersatileImageField( 37 | upload_to='./', 38 | blank=True, 39 | placeholder_image=OnStoragePlaceholderImage( 40 | path='on-storage-placeholder/placeholder.png' 41 | ) 42 | ) 43 | optional_image_3 = VersatileImageField(upload_to='./', blank=True) 44 | ppoi = PPOIField() 45 | 46 | 47 | class VersatileImageTestUploadDirectoryModel(models.Model): 48 | image = VersatileImageField(upload_to='./foo/') 49 | 50 | class Meta: 51 | verbose_name = 'VIF Test Upload Dir Model' 52 | verbose_name_plural = 'VIF Test Upload Dir Models' 53 | 54 | 55 | class VersatileImageWidgetTestModel(models.Model): 56 | """A model for testing VersatileImageField widgets.""" 57 | 58 | image = VersatileImageField(upload_to='./', ppoi_field='ppoi') 59 | image_no_ppoi = VersatileImageField(upload_to='./') 60 | optional_image = VersatileImageField(upload_to='./', blank=True) 61 | optional_image_with_ppoi = VersatileImageField( 62 | upload_to='./', 63 | blank=True, 64 | ppoi_field='optional_image_with_ppoi_ppoi' 65 | ) 66 | optional_image_2 = VersatileImageField(upload_to='./') 67 | required_text_field = models.CharField(max_length=20) 68 | ppoi = PPOIField() 69 | optional_image_with_ppoi_ppoi = PPOIField() 70 | -------------------------------------------------------------------------------- /tests/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/placeholder.png -------------------------------------------------------------------------------- /tests/post_processor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/post_processor/__init__.py -------------------------------------------------------------------------------- /tests/post_processor/discover_tests.py: -------------------------------------------------------------------------------- 1 | from django.test.runner import DiscoverRunner 2 | 3 | 4 | class DiscoverPostProcessorRunner(DiscoverRunner): 5 | 6 | def __init__(self, pattern='post_processor_tests.py', **kwargs): 7 | super(DiscoverPostProcessorRunner, self).__init__(pattern, **kwargs) 8 | -------------------------------------------------------------------------------- /tests/post_processor/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from versatileimagefield.fields import VersatileImageField 4 | from versatileimagefield.placeholder import OnStoragePlaceholderImage 5 | 6 | 7 | class VersatileImagePostProcessorTestModel(models.Model): 8 | """A model for testing VersatileImageFields.""" 9 | 10 | image = VersatileImageField( 11 | upload_to='./', 12 | blank=True, 13 | placeholder_image=OnStoragePlaceholderImage( 14 | path='on-storage-placeholder/placeholder.png' 15 | ) 16 | ) 17 | 18 | class Meta: 19 | verbose_name = 'foo' 20 | verbose_name_plural = 'foos' 21 | -------------------------------------------------------------------------------- /tests/post_processor/post_processor_tests.py: -------------------------------------------------------------------------------- 1 | from .models import VersatileImagePostProcessorTestModel 2 | from ..tests import VersatileImageFieldBaseTestCase 3 | 4 | 5 | class VersatileImageFieldPostProcessorTestCase(VersatileImageFieldBaseTestCase): 6 | 7 | @classmethod 8 | def setUpTestData(cls): 9 | cls.instance = VersatileImagePostProcessorTestModel.objects.create( 10 | image='python-logo.jpg' 11 | ) 12 | 13 | def test_post_processor(self): 14 | """ 15 | Ensure versatileimagefield.registry.autodiscover raises the 16 | appropriate exception when trying to import on versatileimage.py 17 | modules. 18 | """ 19 | self.instance.create_on_demand = True 20 | self.assertEqual( 21 | self.instance.image.crop['100x100'].url, 22 | '/media/__sized__/python-logo-0628a40c3c1b5466.jpg' 23 | ) 24 | 25 | def test_obscured_file_delete(self): 26 | self.assertImageDeleted(self.instance.image) 27 | -------------------------------------------------------------------------------- /tests/post_processor/test_settings.py: -------------------------------------------------------------------------------- 1 | from ..settings_base import * 2 | 3 | INSTALLED_APPS += ['tests', 'tests.post_processor'] 4 | 5 | VERSATILEIMAGEFIELD_SETTINGS = { 6 | # The amount of time, in seconds, that references to created images 7 | # should be stored in the cache. Defaults to `2592000` (30 days) 8 | 'cache_length': 2592000, 9 | # The name of the cache you'd like `django-versatileimagefield` to use. 10 | # Defaults to 'versatileimagefield_cache'. If no cache exists with the name 11 | # provided, the 'default' cache will be used instead. 12 | 'cache_name': 'versatileimagefield_cache', 13 | # The save quality of modified JPEG images. More info here: 14 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#jpeg 15 | # Defaults to 70 16 | 'jpeg_resize_quality': 60, 17 | # The save quality of modified WEBP images. More info here: 18 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 19 | # Defaults to 70 20 | 'webp_resize_quality': 80, 21 | # If true, instructs the WebP writer to use lossless compression. 22 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 23 | # Defaults to False 24 | 'lossless_webp': False, 25 | # A path on disc to an image that will be used as a 'placeholder' 26 | # for non-existent images. 27 | # If 'global_placeholder_image' is unset, the excellent, free-to-use 28 | # http://placehold.it service will be used instead. 29 | 'global_placeholder_image': os.path.join( 30 | PROJECT_DIR, 31 | 'placeholder.gif' 32 | ), 33 | # The name of the top-level folder within storage classes to save all 34 | # sized images. Defaults to '__sized__' 35 | 'sized_directory_name': '__sized__', 36 | # The name of the directory to save all filtered images within. 37 | # Defaults to '__filtered__': 38 | 'filtered_directory_name': '__filtered__', 39 | # Whether or not to create new images on-the-fly. Set this to `False` for 40 | # speedy performance but don't forget to 'pre-warm' to ensure they're 41 | # created and available at the appropriate URL. 42 | 'create_images_on_demand': False, 43 | 'image_key_post_processor': 'versatileimagefield.processors.md5_16' 44 | } 45 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.serializers import ModelSerializer 2 | 3 | from versatileimagefield.serializers import VersatileImageFieldSerializer 4 | 5 | from .models import VersatileImageTestModel 6 | 7 | 8 | class VersatileImageTestModelSerializer(ModelSerializer): 9 | """Serializes VersatileImageTestModel instances""" 10 | image = VersatileImageFieldSerializer( 11 | sizes='test_set' 12 | ) 13 | optional_image = VersatileImageFieldSerializer( 14 | sizes='test_set' 15 | ) 16 | 17 | class Meta: 18 | model = VersatileImageTestModel 19 | exclude = ( 20 | 'img_type', 21 | 'height', 22 | 'width', 23 | 'optional_image_2', 24 | 'optional_image_3' 25 | ) 26 | -------------------------------------------------------------------------------- /tests/settings_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) 4 | MEDIA_ROOT = os.path.join(PROJECT_DIR, 'media') 5 | SECRET_KEY = 'fake-key' 6 | INSTALLED_APPS = [ 7 | "django.contrib.admin", 8 | "django.contrib.auth", 9 | "django.contrib.contenttypes", 10 | "django.contrib.messages", 11 | "django.contrib.sessions", 12 | "django.contrib.staticfiles", 13 | "rest_framework", 14 | "versatileimagefield", 15 | ] 16 | 17 | MIDDLEWARE_CLASSES = ( 18 | 'django.contrib.sessions.middleware.SessionMiddleware', 19 | 'django.middleware.common.CommonMiddleware', 20 | 'django.middleware.csrf.CsrfViewMiddleware', 21 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 22 | 'django.contrib.messages.middleware.MessageMiddleware', 23 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 24 | ) 25 | 26 | MIDDLEWARE = MIDDLEWARE_CLASSES 27 | 28 | DATABASES = { 29 | 'default': { 30 | 'ENGINE': 'django.db.backends.sqlite3', 31 | } 32 | } 33 | 34 | CACHES = { 35 | 'default': { 36 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 37 | 'LOCATION': 'versatileimagefield_testing_cache', 38 | } 39 | } 40 | 41 | MEDIA_URL = '/media/' 42 | STATIC_URL = '/static/' 43 | 44 | VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { 45 | 'test_set': ( 46 | ('test_thumb', 'thumbnail__100x100'), 47 | ('test_crop', 'crop__100x100'), 48 | ('test_invert', 'filters__invert__url'), 49 | ('test_invert_thumb', 'filters__invert__thumbnail__100x100'), 50 | ('test_invert_crop', 'filters__invert__crop__100x100'), 51 | ), 52 | 'invalid_size_key': ( 53 | ('test', 'thumbnail'), 54 | ), 55 | 'invalid_set': ('test_thumb', 'thumbnail__100x100') 56 | } 57 | 58 | ROOT_URLCONF = 'tests.urls' 59 | DEBUG = True 60 | 61 | TEMPLATES = [ 62 | { 63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 64 | 'DIRS': [ 65 | os.path.join(PROJECT_DIR, 'templates'), 66 | ], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 80 | -------------------------------------------------------------------------------- /tests/templates/test-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/test.png -------------------------------------------------------------------------------- /tests/test2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/test2.png -------------------------------------------------------------------------------- /tests/test_autodiscover/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/respondcreate/django-versatileimagefield/8a2922254c360d827eca5d6635d18720204964ea/tests/test_autodiscover/__init__.py -------------------------------------------------------------------------------- /tests/test_autodiscover/versatileimagefield.py: -------------------------------------------------------------------------------- 1 | from .models import Foo 2 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from .settings_base import * 2 | INSTALLED_APPS += ["tests"] 3 | 4 | VERSATILEIMAGEFIELD_SETTINGS = { 5 | # The amount of time, in seconds, that references to created images 6 | # should be stored in the cache. Defaults to `2592000` (30 days) 7 | 'cache_length': 2592000, 8 | # The name of the cache you'd like `django-versatileimagefield` to use. 9 | # Defaults to 'versatileimagefield_cache'. If no cache exists with the name 10 | # provided, the 'default' cache will be used instead. 11 | 'cache_name': 'versatileimagefield_cache', 12 | # The save quality of modified JPEG images. More info here: 13 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#jpeg 14 | # Defaults to 70 15 | 'jpeg_resize_quality': 60, 16 | # The save quality of modified WEBP images. More info here: 17 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 18 | # Defaults to 70 19 | 'webp_resize_quality': 80, 20 | # If true, instructs the WebP writer to use lossless compression. 21 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 22 | # Defaults to False 23 | 'lossless_webp': False, 24 | # A path on disc to an image that will be used as a 'placeholder' 25 | # for non-existent images. 26 | # If 'global_placeholder_image' is unset, the excellent, free-to-use 27 | # http://placehold.it service will be used instead. 28 | 'global_placeholder_image': os.path.join( 29 | PROJECT_DIR, 30 | 'placeholder.gif' 31 | ), 32 | # The name of the top-level folder within storage classes to save all 33 | # sized images. Defaults to '__sized__' 34 | 'sized_directory_name': '__sized__', 35 | # The name of the directory to save all filtered images within. 36 | # Defaults to '__filtered__': 37 | 'filtered_directory_name': '__filtered__', 38 | # Whether or not to create new images on-the-fly. Set this to `False` for 39 | # speedy performance but don't forget to 'pre-warm' to ensure they're 40 | # created and available at the appropriate URL. 41 | 'create_images_on_demand': False 42 | } 43 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import include, path, re_path 3 | from django.contrib import admin 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = [ 8 | path('admin/', admin.site.urls), 9 | ] 10 | 11 | if settings.DEBUG: 12 | urlpatterns = [ 13 | re_path( 14 | r'^media/(?P.*)$', 15 | 'django.views.static.serve', 16 | {'document_root': settings.MEDIA_ROOT, 'show_indexes': True} 17 | ), 18 | path('', include('django.contrib.staticfiles.urls')), 19 | ] + urlpatterns 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.23.0 3 | requires: 4 | pip >= 21.0.1 5 | envlist = 6 | py{3.8,3.9}-django{30,31,32,40,41,50}-drf{314} 7 | 8 | [gh-actions] 9 | python = 10 | 3.8: py3.8 11 | 3.9: py3.9 12 | 13 | [travis:env] 14 | DJANGO = 15 | 3.0: django30 16 | 3.1: django31 17 | 3.2: django32 18 | 4.0: django40 19 | 4.1: django41 20 | 5.0: django50 21 | 22 | [testenv] 23 | passenv = TRAVIS TRAVIS_* GITHUB_* 24 | deps= 25 | coverage 26 | coveralls 27 | testfixtures 28 | pytz 29 | django30: Django>=3.0.13,<3.1.0 30 | django31: Django>=3.1.7,<3.2.0 31 | django32: Django>=3.2.0,<3.3.0 32 | django40: Django>=4.0.0,<4.1.13 33 | django41: Django>=4.1.13,<4.2.9 34 | django50: Django>=4.2.9,<5.0.1 35 | drf314: djangorestframework>=3.14,<3.15 36 | flake8 37 | sitepackages = False 38 | recreate = False 39 | commands = 40 | pip list 41 | flake8 versatileimagefield/ 42 | coverage run --parallel-mode --source=versatileimagefield runtests.py 43 | coverage run --parallel-mode --source=versatileimagefield post_processor_runtests.py 44 | coverage combine 45 | - coveralls --service=github 46 | -------------------------------------------------------------------------------- /versatileimagefield/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'versatileimagefield.apps.VersatileImageFieldConfig' 2 | -------------------------------------------------------------------------------- /versatileimagefield/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VersatileImageFieldConfig(AppConfig): 5 | """The Django app config for django-versatileimagefield.""" 6 | name = 'versatileimagefield' 7 | verbose_name = "VersatileImageField" 8 | 9 | def ready(self): 10 | from .registry import autodiscover 11 | autodiscover() 12 | -------------------------------------------------------------------------------- /versatileimagefield/datastructures/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from .sizedimage import SizedImage 4 | from .filteredimage import FilteredImage, FilterLibrary 5 | 6 | __all__ = [ 7 | 'SizedImage', 8 | 'FilteredImage', 9 | 'FilterLibrary' 10 | ] 11 | -------------------------------------------------------------------------------- /versatileimagefield/datastructures/base.py: -------------------------------------------------------------------------------- 1 | """Base datastructures for manipulated images.""" 2 | from PIL import Image 3 | 4 | from django.core.files.uploadedfile import InMemoryUploadedFile 5 | 6 | from ..settings import ( 7 | JPEG_QUAL, 8 | VERSATILEIMAGEFIELD_PROGRESSIVE_JPEG, 9 | VERSATILEIMAGEFIELD_LOSSLESS_WEBP, 10 | WEBP_QUAL, 11 | ) 12 | from ..utils import get_image_metadata_from_file 13 | 14 | EXIF_ORIENTATION_KEY = 274 15 | 16 | 17 | class ProcessedImage(object): 18 | """ 19 | A base class for processing/saving different renditions of an image. 20 | 21 | Constructor arguments: 22 | * `path_to_image`: A path to a file within `storage` 23 | * `storage`: A django storage class 24 | * `create_on_demand`: A bool signifying whether new images should be 25 | created on-demand. 26 | 27 | Subclasses must define the `process_image` method. see 28 | versatileimagefield.datastructures.filteredimage.FilteredImage and 29 | versatileimagefield.datastructures.sizedimage.SizedImage 30 | for examples. 31 | 32 | Includes a preprocessing API based on image format/file type. See 33 | the `preprocess` method for more specific information. 34 | """ 35 | 36 | name = None 37 | url = None 38 | 39 | def __init__(self, path_to_image, storage, create_on_demand, 40 | placeholder_image=None): 41 | """Construct a ProcessedImage.""" 42 | self.path_to_image = path_to_image 43 | self.storage = storage 44 | self.create_on_demand = create_on_demand 45 | self.placeholder_image = placeholder_image 46 | 47 | def process_image(self, image, image_format, **kwargs): 48 | """ 49 | Ensure NotImplemented is raised if not overloaded by subclasses. 50 | 51 | Arguments: 52 | * `image`: a PIL Image instance 53 | * `image_format`: str, a valid PIL format (i.e. 'JPEG' or 'GIF') 54 | 55 | Returns a BytesIO representation of the resized image. 56 | 57 | Subclasses MUST implement this method. 58 | """ 59 | raise NotImplementedError( 60 | 'Subclasses MUST provide a `process_image` method.' 61 | ) 62 | 63 | def preprocess(self, image, image_format): 64 | """ 65 | Preprocess an image. 66 | 67 | An API hook for image pre-processing. Calls any image format specific 68 | pre-processors (if defined). I.E. If `image_format` is 'JPEG', this 69 | method will look for a method named `preprocess_JPEG`, if found 70 | `image` will be passed to it. 71 | 72 | Arguments: 73 | * `image`: a PIL Image instance 74 | * `image_format`: str, a valid PIL format (i.e. 'JPEG' or 'GIF') 75 | 76 | Subclasses should return a 2-tuple: 77 | * [0]: A PIL Image instance. 78 | * [1]: A dictionary of additional keyword arguments to be used 79 | when the instance is saved. If no additional keyword 80 | arguments, return an empty dict ({}). 81 | """ 82 | save_kwargs = {'format': image_format} 83 | 84 | # Ensuring image is properly rotated 85 | if hasattr(image, '_getexif'): 86 | exif_datadict = image._getexif() # returns None if no EXIF data 87 | if exif_datadict is not None: 88 | exif = dict(exif_datadict.items()) 89 | orientation = exif.get(EXIF_ORIENTATION_KEY, None) 90 | if orientation == 3: 91 | image = image.transpose(Image.ROTATE_180) 92 | elif orientation == 6: 93 | image = image.transpose(Image.ROTATE_270) 94 | elif orientation == 8: 95 | image = image.transpose(Image.ROTATE_90) 96 | 97 | # Ensure any embedded ICC profile is preserved 98 | save_kwargs['icc_profile'] = image.info.get('icc_profile') 99 | 100 | if hasattr(self, 'preprocess_%s' % image_format): 101 | image, addl_save_kwargs = getattr( 102 | self, 103 | 'preprocess_%s' % image_format 104 | )(image=image) 105 | save_kwargs.update(addl_save_kwargs) 106 | 107 | return image, save_kwargs 108 | 109 | def preprocess_GIF(self, image, **kwargs): 110 | """ 111 | Receive a PIL Image instance of a GIF and return 2-tuple. 112 | 113 | Args: 114 | * [0]: Original Image instance (passed to `image`) 115 | * [1]: Dict with a transparency key (to GIF transparency layer) 116 | """ 117 | if 'transparency' in image.info: 118 | save_kwargs = {'transparency': image.info['transparency']} 119 | else: 120 | save_kwargs = {} 121 | return (image, save_kwargs) 122 | 123 | def preprocess_JPEG(self, image, **kwargs): 124 | """ 125 | Receive a PIL Image instance of a JPEG and returns 2-tuple. 126 | 127 | Args: 128 | * [0]: Image instance, converted to RGB 129 | * [1]: Dict with a quality key (mapped to the value of `JPEG_QUAL` 130 | defined by the `VERSATILEIMAGEFIELD_JPEG_RESIZE_QUALITY` 131 | setting) 132 | """ 133 | save_kwargs = { 134 | 'progressive': VERSATILEIMAGEFIELD_PROGRESSIVE_JPEG, 135 | 'quality': JPEG_QUAL 136 | } 137 | if image.mode != 'RGB': 138 | image = image.convert('RGB') 139 | return (image, save_kwargs) 140 | 141 | def preprocess_WEBP(self, image, **kwargs): 142 | """ 143 | Receive a PIL Image instance of a WEBP and return 2-tuple. 144 | 145 | Args: 146 | * [0]: Original Image instance (passed to `image`) 147 | * [1]: Dict with a quality key (mapped to the value of `WEBP_QUAL` 148 | as defined by the `VERSATILEIMAGEFIELD_RESIZE_QUALITY` 149 | setting) 150 | """ 151 | save_kwargs = { 152 | "quality": WEBP_QUAL, 153 | "lossless": VERSATILEIMAGEFIELD_LOSSLESS_WEBP, 154 | "icc_profile": image.info.get('icc_profile', '') 155 | } 156 | 157 | return (image, save_kwargs) 158 | 159 | def retrieve_image(self, path_to_image): 160 | """Return a PIL Image instance stored at `path_to_image`.""" 161 | image = self.storage.open(path_to_image, 'rb') 162 | image_format, mime_type = get_image_metadata_from_file(image) 163 | file_ext = path_to_image.rsplit('.')[-1] 164 | 165 | return ( 166 | Image.open(image), 167 | file_ext, 168 | image_format, 169 | mime_type 170 | ) 171 | 172 | def save_image(self, imagefile, save_path, file_ext, mime_type): 173 | """ 174 | Save an image to self.storage at `save_path`. 175 | 176 | Arguments: 177 | `imagefile`: Raw image data, typically a BytesIO instance. 178 | `save_path`: The path within self.storage where the image should 179 | be saved. 180 | `file_ext`: The file extension of the image-to-be-saved. 181 | `mime_type`: A valid image mime type (as found in 182 | versatileimagefield.utils) 183 | """ 184 | file_to_save = InMemoryUploadedFile( 185 | imagefile, 186 | None, 187 | 'foo.%s' % file_ext, 188 | mime_type, 189 | imagefile.tell(), 190 | None 191 | ) 192 | file_to_save.seek(0) 193 | self.storage.save(save_path, file_to_save) 194 | -------------------------------------------------------------------------------- /versatileimagefield/datastructures/filteredimage.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from ..settings import ( 4 | cache, 5 | VERSATILEIMAGEFIELD_CACHE_LENGTH 6 | ) 7 | from ..utils import get_filtered_path 8 | 9 | from .base import ProcessedImage 10 | from .mixins import DeleteAndClearCacheMixIn 11 | 12 | 13 | class InvalidFilter(Exception): 14 | pass 15 | 16 | 17 | class FilteredImage(DeleteAndClearCacheMixIn, ProcessedImage): 18 | """ 19 | A ProcessedImage subclass that applies a filter to an image. 20 | 21 | Constructor arguments: 22 | * `path_to_image`: The path within `storage` of the image 23 | to filter. 24 | * `storage`: A django storage class. 25 | * `filename_key`: A string that is included in the filtered 26 | filename to identify it. This should be short 27 | and descriptive (i.e. 'grayscale' or 'invert') 28 | 29 | Subclasses must implement a process_image method. 30 | """ 31 | 32 | def __init__(self, path_to_image, storage, create_on_demand, filename_key): 33 | super(FilteredImage, self).__init__( 34 | path_to_image, storage, create_on_demand 35 | ) 36 | self.name = get_filtered_path( 37 | path_to_image=self.path_to_image, 38 | filename_key=filename_key, 39 | storage=storage 40 | ) 41 | 42 | self.url = storage.url(self.name) 43 | 44 | def create_filtered_image(self, path_to_image, save_path_on_storage): 45 | """ 46 | Creates a filtered image. 47 | `path_to_image`: The path to the image with the media directory 48 | to resize. 49 | `save_path_on_storage`: Where on self.storage to save the filtered 50 | image 51 | """ 52 | 53 | image, file_ext, image_format, mime_type = self.retrieve_image( 54 | path_to_image 55 | ) 56 | image, save_kwargs = self.preprocess(image, image_format) 57 | imagefile = self.process_image(image, image_format, save_kwargs) 58 | self.save_image(imagefile, save_path_on_storage, file_ext, mime_type) 59 | 60 | def __str__(self): 61 | return self.url 62 | 63 | 64 | class DummyFilter(object): 65 | """ 66 | A 'dummy' version of FilteredImage which is only used if 67 | settings.VERSATILEIMAGEFIELD_USE_PLACEHOLDIT is True 68 | """ 69 | name = '' 70 | url = '' 71 | 72 | 73 | class FilterLibrary(dict): 74 | """ 75 | Exposes all filters registered with the sizedimageregistry 76 | (via sizedimageregistry.register_filter) to each VersatileImageField. 77 | 78 | Each filter also has access to each 'sizer' registered with 79 | sizedimageregistry (via sizedimageregistry.register_sizer) 80 | """ 81 | 82 | def __init__(self, original_file_location, 83 | storage, registry, ppoi, create_on_demand): 84 | self.original_file_location = original_file_location 85 | self.storage = storage 86 | self.registry = registry 87 | self.ppoi = ppoi 88 | self.create_on_demand = create_on_demand 89 | 90 | def __getattr__(self, key): 91 | return self[key] 92 | 93 | def __getitem__(self, key): 94 | """ 95 | Returns a FilteredImage instance built from the FilteredImage subclass 96 | associated with self.registry[key] 97 | 98 | If no FilteredImage subclass is associated with self.registry[key], 99 | InvalidFilter will raise. 100 | """ 101 | try: 102 | # FilteredImage instances are not built until they're accessed for 103 | # the first time (in order to cut down on memory usage and disk 104 | # space) However, once built, they can be accessed directly by 105 | # calling the dict superclass's __getitem__ (which avoids the 106 | # infinite loop that would be caused by using self[key] 107 | # or self.get(key)) 108 | prepped_filter = dict.__getitem__(self, key) 109 | except KeyError: 110 | # See if `key` is associated with a valid filter. 111 | if key not in self.registry._filter_registry: 112 | raise InvalidFilter('`%s` is an invalid filter.' % key) 113 | else: 114 | # Handling 'empty' fields. 115 | if not self.original_file_location and getattr( 116 | settings, 'VERSATILEIMAGEFIELD_USE_PLACEHOLDIT', False 117 | ): 118 | # If VERSATILEIMAGEFIELD_USE_PLACEHOLDIT is True (i.e. 119 | # settings.VERSATILEIMAGEFIELD_PLACEHOLDER_IMAGE is unset) 120 | # use DummyFilter (so sized renditions can still return 121 | # valid http://placehold.it URLs). 122 | filtered_path = None 123 | prepped_filter = DummyFilter() 124 | else: 125 | filtered_path = get_filtered_path( 126 | path_to_image=self.original_file_location, 127 | filename_key=key, 128 | storage=self.storage 129 | ) 130 | 131 | filtered_url = self.storage.url(filtered_path) 132 | 133 | filter_cls = self.registry._filter_registry[key] 134 | prepped_filter = filter_cls( 135 | path_to_image=self.original_file_location, 136 | storage=self.storage, 137 | create_on_demand=self.create_on_demand, 138 | filename_key=key 139 | ) 140 | if self.create_on_demand is True: 141 | if cache.get(filtered_url): 142 | # The filtered_url exists in the cache so the image 143 | # already exists. So we `pass` to skip directly to 144 | # the return statement. 145 | pass 146 | else: 147 | if not self.storage.exists(filtered_path): 148 | prepped_filter.create_filtered_image( 149 | path_to_image=self.original_file_location, 150 | save_path_on_storage=filtered_path 151 | ) 152 | 153 | # Setting a super-long cache for the newly created 154 | # image 155 | cache.set( 156 | filtered_url, 157 | 1, 158 | VERSATILEIMAGEFIELD_CACHE_LENGTH 159 | ) 160 | 161 | # 'Bolting' all image sizers within 162 | # `self.registry._sizedimage_registry` onto 163 | # the prepped_filter instance 164 | for ( 165 | attr_name, sizedimage_cls 166 | ) in self.registry._sizedimage_registry.items(): 167 | setattr( 168 | prepped_filter, 169 | attr_name, 170 | sizedimage_cls( 171 | path_to_image=filtered_path, 172 | storage=self.storage, 173 | create_on_demand=self.create_on_demand, 174 | ppoi=self.ppoi 175 | ) 176 | ) 177 | # Assigning `prepped_filter` to `key` so future access 178 | # is fast/cheap 179 | self[key] = prepped_filter 180 | 181 | return dict.__getitem__(self, key) 182 | -------------------------------------------------------------------------------- /versatileimagefield/datastructures/mixins.py: -------------------------------------------------------------------------------- 1 | from ..settings import cache 2 | 3 | 4 | class DeleteAndClearCacheMixIn(object): 5 | 6 | def clear_cache(self): 7 | cache.delete(self.url) 8 | 9 | def delete(self): 10 | self.storage.delete(self.name) 11 | self.clear_cache() 12 | -------------------------------------------------------------------------------- /versatileimagefield/datastructures/sizedimage.py: -------------------------------------------------------------------------------- 1 | """Datastructures for sizing images.""" 2 | from django.conf import settings 3 | from ..settings import ( 4 | cache, 5 | VERSATILEIMAGEFIELD_CACHE_LENGTH 6 | ) 7 | from ..utils import get_resized_path 8 | from .base import ProcessedImage 9 | from .mixins import DeleteAndClearCacheMixIn 10 | 11 | 12 | class MalformedSizedImageKey(Exception): 13 | """An Exception for improperly constructured sized image keys.""" 14 | 15 | pass 16 | 17 | 18 | class SizedImageInstance(DeleteAndClearCacheMixIn): 19 | """A simple class for images created by SizedImage.""" 20 | 21 | def __init__(self, name, url, storage): 22 | """Construct a SizedImageInstance.""" 23 | self.name = name 24 | self.url = url 25 | self.storage = storage 26 | 27 | def __str__(self): 28 | """Return the string representation.""" 29 | return self.url 30 | 31 | 32 | class SizedImage(ProcessedImage, dict): 33 | """ 34 | A dict subclass that exposes an image sizing API via key access. 35 | 36 | Subclasses must implement a `process_image` method. 37 | 38 | See versatileimagefield.versatileimagefield.CroppedImage and 39 | versatileimagefield.versatileimagefield.ThumbnailImage for subclass 40 | examples. 41 | """ 42 | 43 | def __init__(self, path_to_image, storage, create_on_demand, ppoi=None): 44 | """Construct a SizedImage.""" 45 | super(SizedImage, self).__init__( 46 | path_to_image, storage, create_on_demand 47 | ) 48 | self.ppoi = ppoi 49 | try: 50 | key = self.get_filename_key() 51 | except AttributeError: 52 | raise NotImplementedError( 53 | 'SizedImage subclasses must define a ' 54 | '`filename_key` attribute or override the ' 55 | '`get_filename_key` method.' 56 | ) 57 | else: 58 | del key 59 | 60 | def ppoi_as_str(self): 61 | """Return PPOI value as a string.""" 62 | return "%s__%s" % ( 63 | str(self.ppoi[0]).replace('.', '-'), 64 | str(self.ppoi[1]).replace('.', '-') 65 | ) 66 | 67 | def get_filename_key(self): 68 | """Return a string used to identify the resized image.""" 69 | return self.filename_key 70 | 71 | @classmethod 72 | def get_filename_key_regex(cls): 73 | """Return the filename key regex.""" 74 | try: 75 | return cls.filename_key_regex 76 | except AttributeError: 77 | try: 78 | return cls.filename_key 79 | except AttributeError: # pragma: no cover 80 | raise NotImplementedError( 81 | 'SizedImage subclasses must define a ' 82 | '`filename_key_regex` attribute or a ' 83 | '`filename_key` attribute or override the ' 84 | '`get_filename_key_regex` class method.' 85 | ) 86 | 87 | def __setitem__(self, key, value): 88 | """Ensure attribute assignment is disabled.""" 89 | raise NotImplementedError( 90 | '%s instances do not allow key' 91 | ' assignment.' % self.__class__.__name__ 92 | ) 93 | 94 | def __getitem__(self, key): 95 | """ 96 | Return a URL to an image sized according to key. 97 | 98 | Arguments: 99 | * `key`: A string in the following format 100 | '[width-in-pixels]x[height-in-pixels]' 101 | Example: '400x400' 102 | """ 103 | try: 104 | width, height = [int(i) for i in key.split('x')] 105 | except (KeyError, ValueError): 106 | raise MalformedSizedImageKey( 107 | "%s keys must be in the following format: " 108 | "'`width`x`height`' where both `width` and `height` are " 109 | "integers." % self.__class__.__name__ 110 | ) 111 | 112 | if not self.path_to_image and getattr( 113 | settings, 'VERSATILEIMAGEFIELD_USE_PLACEHOLDIT', False 114 | ): 115 | resized_url = "http://placehold.it/%dx%d" % (width, height) 116 | resized_storage_path = resized_url 117 | else: 118 | resized_storage_path = get_resized_path( 119 | path_to_image=self.path_to_image, 120 | width=width, 121 | height=height, 122 | filename_key=self.get_filename_key(), 123 | storage=self.storage 124 | ) 125 | 126 | try: 127 | resized_url = self.storage.url(resized_storage_path) 128 | except Exception: # pragma: no cover 129 | resized_url = None 130 | 131 | if self.create_on_demand is True: 132 | if cache.get(resized_url) and resized_url is not None: 133 | # The sized path exists in the cache so the image already 134 | # exists. So we `pass` to skip directly to the return 135 | # statement 136 | pass 137 | else: 138 | if resized_storage_path and not self.storage.exists( 139 | resized_storage_path 140 | ): 141 | self.create_resized_image( 142 | path_to_image=self.path_to_image, 143 | save_path_on_storage=resized_storage_path, 144 | width=width, 145 | height=height 146 | ) 147 | 148 | resized_url = self.storage.url(resized_storage_path) 149 | 150 | # Setting a super-long cache for a resized image (30 Days) 151 | cache.set(resized_url, 1, VERSATILEIMAGEFIELD_CACHE_LENGTH) 152 | return SizedImageInstance( 153 | name=resized_storage_path, 154 | url=resized_url, 155 | storage=self.storage 156 | ) 157 | 158 | def process_image(self, image, image_format, save_kwargs, 159 | width, height): 160 | """ 161 | Process a SizedImage. 162 | 163 | Arguments: 164 | * `image`: a PIL Image instance 165 | * `image_format`: A valid image mime type (e.g. 'image/jpeg') 166 | * `save_kwargs`: A dict of any keyword arguments needed during 167 | save that are provided by the preprocessing API. 168 | * `width`: value in pixels (as int) representing the intended width 169 | * `height`: value in pixels (as int) representing the intended 170 | height 171 | 172 | 173 | Returns a BytesIO representation of the resized image. 174 | 175 | Subclasses MUST implement this method. 176 | """ 177 | raise NotImplementedError( 178 | 'Subclasses MUST provide a `process_image` method.' 179 | ) 180 | 181 | def create_resized_image(self, path_to_image, save_path_on_storage, 182 | width, height): 183 | """ 184 | Create a resized image. 185 | 186 | `path_to_image`: The path to the image with the media directory to 187 | resize. If `None`, the 188 | VERSATILEIMAGEFIELD_PLACEHOLDER_IMAGE will be used. 189 | `save_path_on_storage`: Where on self.storage to save the resized image 190 | `width`: Width of resized image (int) 191 | `height`: Desired height of resized image (int) 192 | `filename_key`: A string that will be used in the sized image filename 193 | to signify what operation was done to it. 194 | Examples: 'crop' or 'scale' 195 | """ 196 | image, file_ext, image_format, mime_type = self.retrieve_image( 197 | path_to_image 198 | ) 199 | 200 | image, save_kwargs = self.preprocess(image, image_format) 201 | 202 | imagefile = self.process_image( 203 | image=image, 204 | image_format=image_format, 205 | save_kwargs=save_kwargs, 206 | width=width, 207 | height=height 208 | ) 209 | self.save_image(imagefile, save_path_on_storage, file_ext, mime_type) 210 | -------------------------------------------------------------------------------- /versatileimagefield/fields.py: -------------------------------------------------------------------------------- 1 | """Fields.""" 2 | import os 3 | 4 | from django.contrib.admin.widgets import AdminFileWidget 5 | from django.db.models.fields import CharField 6 | from django.db.models.fields.files import ImageField 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from .files import VersatileImageFieldFile, VersatileImageFileDescriptor 10 | from .forms import SizedImageCenterpointClickDjangoAdminField 11 | from .placeholder import OnStoragePlaceholderImage 12 | from .settings import VERSATILEIMAGEFIELD_PLACEHOLDER_DIRNAME 13 | from .validators import validate_ppoi 14 | 15 | 16 | class Creator(object): 17 | """Provides a way to set the attribute on the model.""" 18 | 19 | def __init__(self, field): 20 | self.field = field 21 | 22 | def __get__(self, obj, objtype=None): 23 | return obj.__dict__[self.field.name] 24 | 25 | def __set__(self, obj, value): 26 | obj.__dict__[self.field.name] = self.field.to_python(value) 27 | 28 | 29 | class VersatileImageField(ImageField): 30 | """Extends ImageField.""" 31 | 32 | attr_class = VersatileImageFieldFile 33 | descriptor_class = VersatileImageFileDescriptor 34 | description = _('Versatile Image Field') 35 | 36 | def __init__(self, verbose_name=None, name=None, width_field=None, 37 | height_field=None, ppoi_field=None, placeholder_image=None, 38 | **kwargs): 39 | """Initialize an instance.""" 40 | self.ppoi_field = ppoi_field 41 | super(VersatileImageField, self).__init__( 42 | verbose_name, name, width_field, height_field, **kwargs 43 | ) 44 | self.placeholder_image = placeholder_image 45 | self.placeholder_image_name = None 46 | 47 | def process_placeholder_image(self): 48 | """ 49 | Process the field's placeholder image. 50 | 51 | Ensures the placeholder image has been saved to the same storage class 52 | as the field in a top level folder with a name specified by 53 | settings.VERSATILEIMAGEFIELD_SETTINGS['placeholder_directory_name'] 54 | 55 | This should be called by the VersatileImageFileDescriptor __get__. 56 | If self.placeholder_image_name is already set it just returns right away. 57 | """ 58 | if self.placeholder_image_name: 59 | return 60 | 61 | placeholder_image_name = None 62 | placeholder_image = self.placeholder_image 63 | if placeholder_image: 64 | if isinstance(placeholder_image, OnStoragePlaceholderImage): 65 | name = placeholder_image.path 66 | else: 67 | name = placeholder_image.image_data.name 68 | placeholder_image_name = os.path.join( 69 | VERSATILEIMAGEFIELD_PLACEHOLDER_DIRNAME, name 70 | ) 71 | if not self.storage.exists(placeholder_image_name): 72 | self.storage.save( 73 | placeholder_image_name, 74 | placeholder_image.image_data 75 | ) 76 | self.placeholder_image_name = placeholder_image_name 77 | 78 | def pre_save(self, model_instance, add): 79 | """Return field's value just before saving.""" 80 | file = super(VersatileImageField, self).pre_save(model_instance, add) 81 | self.update_ppoi_field(model_instance) 82 | return file 83 | 84 | def update_ppoi_field(self, instance, *args, **kwargs): 85 | """ 86 | Update field's ppoi field, if defined. 87 | 88 | This method is hooked up this field's pre_save method to update 89 | the ppoi immediately before the model instance (`instance`) 90 | it is associated with is saved. 91 | 92 | This field's ppoi can be forced to update with force=True, 93 | which is how VersatileImageField.pre_save calls this method. 94 | """ 95 | # Nothing to update if the field doesn't have have a ppoi 96 | # dimension field. 97 | if not self.ppoi_field: 98 | return 99 | 100 | # getattr will call the VersatileImageFileDescriptor's __get__ method, 101 | # which coerces the assigned value into an instance of 102 | # self.attr_class(VersatileImageFieldFile in this case). 103 | file = getattr(instance, self.attname) 104 | 105 | # file should be an instance of VersatileImageFieldFile or should be 106 | # None. 107 | ppoi = None 108 | if file and not isinstance(file, tuple): 109 | if hasattr(file, 'ppoi'): 110 | ppoi = file.ppoi 111 | 112 | # Update the ppoi field. 113 | if self.ppoi_field: 114 | setattr(instance, self.ppoi_field, ppoi) 115 | 116 | def save_form_data(self, instance, data): 117 | """ 118 | Handle data sent from MultiValueField forms that set ppoi values. 119 | 120 | `instance`: The model instance that is being altered via a form 121 | `data`: The data sent from the form to this field which can be either: 122 | * `None`: This is unset data from an optional field 123 | * A two-position tuple: (image_form_data, ppoi_data) 124 | * `image_form-data` options: 125 | * `None` the file for this field is unchanged 126 | * `False` unassign the file form the field 127 | * `ppoi_data` data structure: 128 | * `%(x_coordinate)sx%(y_coordinate)s': The ppoi data to 129 | assign to the unchanged file 130 | 131 | """ 132 | to_assign = data 133 | if data and isinstance(data, tuple): 134 | # This value is coming from a MultiValueField 135 | if data[0] is None: 136 | # This means the file hasn't changed but we need to 137 | # update the ppoi 138 | current_field = getattr(instance, self.name) 139 | if data[1]: 140 | current_field.ppoi = data[1] 141 | to_assign = current_field 142 | elif data[0] is False: 143 | # This means the 'Clear' checkbox was checked so we 144 | # need to empty the field 145 | to_assign = '' 146 | else: 147 | # This means there is a new upload so we need to unpack 148 | # the tuple and assign the first position to the field 149 | # attribute 150 | to_assign = data[0] 151 | super(VersatileImageField, self).save_form_data(instance, to_assign) 152 | 153 | def formfield(self, **kwargs): 154 | """Return a formfield.""" 155 | # This is a fairly standard way to set up some defaults 156 | # while letting the caller override them. 157 | defaults = {} 158 | if self.ppoi_field: 159 | defaults['form_class'] = SizedImageCenterpointClickDjangoAdminField 160 | if kwargs.get('widget') is AdminFileWidget: 161 | # Ensuring default admin widget is skipped (in favor of using 162 | # SizedImageCenterpointClickDjangoAdminField's default widget as 163 | # the default widget choice for use in the admin). 164 | # This is for two reasons: 165 | # 1. To prevent 'typical' admin users (those who want to use 166 | # the PPOI 'click' widget by default) from having to 167 | # specify a formfield_overrides for each ModelAdmin class 168 | # used by each model that has a VersatileImageField. 169 | # 2. If a VersatileImageField does not have a ppoi_field specified 170 | # it will 'fall back' to a ClearableFileInput anyways. 171 | # If admin users do, in fact, want to force use of the 172 | # AdminFileWidget they can simply subclass AdminFileWidget and 173 | # specify it in their ModelAdmin.formfield_overrides (though, 174 | # if that's the case, why are they using VersatileImageField in 175 | # the first place?) 176 | del kwargs['widget'] 177 | defaults.update(kwargs) 178 | return super(VersatileImageField, self).formfield(**defaults) 179 | 180 | 181 | class PPOIField(CharField): 182 | 183 | def __init__(self, *args, **kwargs): 184 | if 'default' not in kwargs: 185 | kwargs['default'] = '0.5x0.5' 186 | kwargs['default'] = self.get_prep_value( 187 | value=validate_ppoi( 188 | kwargs['default'], 189 | return_converted_tuple=True 190 | ) 191 | ) 192 | if 'max_length' not in kwargs: 193 | kwargs['max_length'] = 20 194 | # Forcing editable = False since PPOI values are set directly on 195 | # VersatileImageField. 196 | kwargs['editable'] = False 197 | super(PPOIField, self).__init__(*args, **kwargs) 198 | self.validators.append(validate_ppoi) 199 | 200 | def contribute_to_class(self, cls, name, **kwargs): 201 | super(PPOIField, self).contribute_to_class(cls, name, **kwargs) 202 | setattr(cls, self.name, Creator(self)) 203 | 204 | def from_db_value(self, value, *args, **kwargs): 205 | return self.to_python(value) 206 | 207 | def to_python(self, value): 208 | if value is None: 209 | value = '0.5x0.5' 210 | to_return = validate_ppoi( 211 | value, return_converted_tuple=True 212 | ) 213 | return to_return 214 | 215 | def get_prep_value(self, value): 216 | if isinstance(value, tuple): 217 | value = 'x'.join(str(num) for num in value) 218 | return value 219 | 220 | def value_to_string(self, obj): 221 | """Prepare field for serialization.""" 222 | value = self.value_from_object(obj) 223 | return self.get_prep_value(value) 224 | 225 | 226 | __all__ = ['VersatileImageField', 'PPOIField'] 227 | -------------------------------------------------------------------------------- /versatileimagefield/files.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | from django.core.files.base import File 3 | from django.db.models.fields.files import ( 4 | FieldFile, 5 | ImageFieldFile, 6 | ImageFileDescriptor 7 | ) 8 | 9 | from .mixins import VersatileImageMixIn 10 | 11 | 12 | class VersatileImageFieldFile(VersatileImageMixIn, ImageFieldFile): 13 | 14 | def __setstate__(self, state): 15 | self.__dict__.update(state) 16 | if DJANGO_VERSION >= (3, 1): 17 | self.storage = self.field.storage 18 | self._create_on_demand = state.get('_create_on_demand') 19 | self._ppoi_value = state.get('_ppoi_value') 20 | 21 | def __getstate__(self): 22 | # VersatileImageFieldFile needs access to its associated model field 23 | # and an instance it's attached to in order to work properly, but the 24 | # only necessary data to be pickled is the file's name itself. 25 | # Everything else will be restored later, by 26 | # VersatileImageFileDescriptor below. 27 | state = super().__getstate__() 28 | state['_create_on_demand'] = self._create_on_demand 29 | state['_ppoi_value'] = self._ppoi_value 30 | return state 31 | 32 | 33 | class VersatileImageFileDescriptor(ImageFileDescriptor): 34 | 35 | def __set__(self, instance, value): 36 | previous_file = instance.__dict__.get(self.field.name) 37 | super().__set__(instance, value) 38 | 39 | # Updating ppoi_field on attribute set 40 | if previous_file is not None: 41 | self.field.update_dimension_fields(instance, force=True) 42 | self.field.update_ppoi_field(instance) 43 | 44 | def __get__(self, instance=None, owner=None): 45 | if instance is None: # pragma: no cover 46 | return self 47 | 48 | # This is slightly complicated, so worth an explanation. 49 | # instance.file`needs to ultimately return some instance of `File`, 50 | # probably a subclass. Additionally, this returned object needs to have 51 | # the VersatileImageFieldFile API so that users can easily do things 52 | # like instance.file.path & have that delegated to the file storage 53 | # engine. Easy enough if we're strict about assignment in __set__, but 54 | # if you peek below you can see that we're not. So depending on the 55 | # current value of the field we have to dynamically construct some 56 | # sort of "thing" to return. 57 | 58 | # The instance dict contains whatever was originally assigned 59 | # in __set__. 60 | file = instance.__dict__[self.field.name] 61 | 62 | # Call the placeholder process method on VersatileImageField. 63 | # (This was called inside the VersatileImageField __init__ before) Fixes #28 64 | self.field.process_placeholder_image() 65 | 66 | # If this value is a string (instance.file = "path/to/file") or None 67 | # then we simply wrap it with the appropriate attribute class according 68 | # to the file field. [This is FieldFile for FileFields and 69 | # ImageFieldFile for ImageFields and their subclasses, like this class; 70 | # it's also conceivable that user subclasses might also want to 71 | # subclass the attribute class]. This object understands how to convert 72 | # a path to a file, and also how to handle None. 73 | if isinstance(file, str) or file is None: 74 | attr = self.field.attr_class( 75 | instance=instance, 76 | field=self.field, 77 | name=file 78 | ) 79 | # Check if this field has a ppoi_field assigned 80 | if attr.field.ppoi_field: 81 | # Pulling the current value of the ppoi_field... 82 | ppoi = instance.__dict__[attr.field.ppoi_field] 83 | # ...and assigning it to VersatileImageField instance 84 | attr.ppoi = ppoi 85 | instance.__dict__[self.field.name] = attr 86 | 87 | # Other types of files may be assigned as well, but they need to have 88 | # the FieldFile interface added to the. Thus, we wrap any other type of 89 | # File inside a FieldFile (well, the field's attr_class, which is 90 | # usually FieldFile). 91 | elif isinstance(file, File) and not isinstance(file, FieldFile): 92 | file_copy = self.field.attr_class(instance, self.field, file.name) 93 | file_copy.file = file 94 | file_copy._committed = False 95 | instance.__dict__[self.field.name] = file_copy 96 | 97 | # Finally, because of the (some would say boneheaded) way pickle works, 98 | # the underlying FieldFile might not actually itself have an associated 99 | # file. So we need to reset the details of the FieldFile in those cases 100 | elif isinstance(file, FieldFile) and not hasattr(file, 'field'): # pragma: no cover 101 | file.instance = instance 102 | file.field = self.field 103 | file.storage = self.field.storage 104 | 105 | if file.field.ppoi_field: 106 | ppoi = instance.__dict__[file.field.ppoi_field] 107 | file.ppoi = ppoi 108 | 109 | # That was fun, wasn't it? 110 | # Finally, ensure all the sizers/filters are available after pickling 111 | to_return = instance.__dict__[self.field.name] 112 | to_return.build_filters_and_sizers(to_return.ppoi, to_return.create_on_demand) 113 | instance.__dict__[self.field.name] = to_return 114 | return instance.__dict__[self.field.name] 115 | -------------------------------------------------------------------------------- /versatileimagefield/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms.fields import MultiValueField, CharField, ImageField 2 | 3 | from .widgets import ( 4 | SizedImageCenterpointClickDjangoAdminWidget, SizedImageCenterpointClickBootstrap3Widget, 5 | VersatileImagePPOIClickWidget 6 | ) 7 | 8 | 9 | class SizedImageCenterpointMixIn(object): 10 | 11 | def compress(self, data_list): 12 | return tuple(data_list) 13 | 14 | 15 | class VersatileImageFormField(ImageField): 16 | 17 | def to_python(self, data): 18 | """Ensure data is prepped properly before handing off to ImageField.""" 19 | if data is not None: 20 | if hasattr(data, 'open'): 21 | data.open() 22 | return super(VersatileImageFormField, self).to_python(data) 23 | 24 | 25 | class VersatileImagePPOIClickField(SizedImageCenterpointMixIn, MultiValueField): 26 | 27 | widget = VersatileImagePPOIClickWidget 28 | 29 | def __init__(self, *args, **kwargs): 30 | max_length = kwargs.pop('max_length', None) 31 | del max_length 32 | fields = ( 33 | VersatileImageFormField(label='Image'), 34 | CharField(required=False) 35 | ) 36 | super(VersatileImagePPOIClickField, self).__init__( 37 | tuple(fields), *args, **kwargs 38 | ) 39 | 40 | def bound_data(self, data, initial): 41 | to_return = data 42 | if data[0] is None: 43 | to_return = initial 44 | return to_return 45 | 46 | 47 | class SizedImageCenterpointClickDjangoAdminField(VersatileImagePPOIClickField): 48 | 49 | widget = SizedImageCenterpointClickDjangoAdminWidget 50 | # Need to remove `None` and `u''` so required fields will work 51 | # TODO: Better validation handling 52 | empty_values = [[], (), {}] 53 | 54 | 55 | class SizedImageCenterpointClickBootstrap3Field(SizedImageCenterpointClickDjangoAdminField): 56 | 57 | widget = SizedImageCenterpointClickBootstrap3Widget 58 | -------------------------------------------------------------------------------- /versatileimagefield/image_warmer.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | import logging 3 | from sys import stdout 4 | 5 | from django.db.models import Model 6 | from django.db.models.query import QuerySet 7 | 8 | from .utils import ( 9 | get_rendition_key_set, 10 | get_url_from_image_key, 11 | validate_versatileimagefield_sizekey_list 12 | ) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def cli_progress_bar(start, end, bar_length=50): 18 | """ 19 | Prints out a Yum-style progress bar (via sys.stdout.write). 20 | `start`: The 'current' value of the progress bar. 21 | `end`: The '100%' value of the progress bar. 22 | `bar_length`: The size of the overall progress bar. 23 | 24 | Example output with start=20, end=100, bar_length=50: 25 | [###########----------------------------------------] 20/100 (100%) 26 | 27 | Intended to be used in a loop. Example: 28 | end = 100 29 | for i in range(end): 30 | cli_progress_bar(i, end) 31 | 32 | Based on an implementation found here: 33 | http://stackoverflow.com/a/13685020/1149774 34 | """ 35 | percent = float(start) / end 36 | hashes = '#' * int(round(percent * bar_length)) 37 | spaces = '-' * (bar_length - len(hashes)) 38 | stdout.write( 39 | "\r[{0}] {1}/{2} ({3}%)".format( 40 | hashes + spaces, 41 | start, 42 | end, 43 | int(round(percent * 100)) 44 | ) 45 | ) 46 | stdout.flush() 47 | 48 | 49 | class VersatileImageFieldWarmer(object): 50 | """ 51 | A class for creating sets of images from a VersatileImageField 52 | """ 53 | 54 | def __init__(self, instance_or_queryset, 55 | rendition_key_set, image_attr, verbose=False): 56 | """ 57 | Arguments: 58 | `instance_or_queryset`: A django model instance or QuerySet 59 | `rendition_key_set`: Either a string that corresponds to a key on 60 | settings.VERSATILEIMAGEFIELD_RENDITION_KEY_SETS 61 | or an iterable 62 | of 2-tuples, both strings: 63 | [0]: The 'name' of the image size. 64 | [1]: A VersatileImageField 'size_key'. 65 | Example: [ 66 | ('large', 'url'), 67 | ('medium', 'crop__400x400'), 68 | ('small', 'thumbnail__100x100') 69 | ] 70 | `image_attr`: A dot-notated path to a VersatileImageField on 71 | `instance_or_queryset` 72 | `verbose`: bool signifying whether a progress bar should be printed 73 | to sys.stdout 74 | """ 75 | if isinstance(instance_or_queryset, Model): 76 | queryset = instance_or_queryset.__class__._default_manager.filter( 77 | pk=instance_or_queryset.pk 78 | ) 79 | elif isinstance(instance_or_queryset, QuerySet): 80 | queryset = instance_or_queryset 81 | else: 82 | raise ValueError( 83 | "Only django model instances or QuerySets can be processed by " 84 | "{}".format(self.__class__.__name__) 85 | ) 86 | self.queryset = queryset 87 | if isinstance(rendition_key_set, str): 88 | rendition_key_set = get_rendition_key_set(rendition_key_set) 89 | self.size_key_list = [ 90 | size_key 91 | for key, size_key in validate_versatileimagefield_sizekey_list( 92 | rendition_key_set 93 | ) 94 | ] 95 | self.image_attr = image_attr 96 | self.verbose = verbose 97 | 98 | @staticmethod 99 | def _prewarm_versatileimagefield(size_key, versatileimagefieldfile): 100 | """ 101 | Returns a 2-tuple: 102 | 0: bool signifying whether the image was successfully pre-warmed 103 | 1: The url of the successfully created image OR the path on storage of 104 | the image that was not able to be successfully created. 105 | 106 | Arguments: 107 | `size_key_list`: A list of VersatileImageField size keys. Examples: 108 | * 'crop__800x450' 109 | * 'thumbnail__800x800' 110 | `versatileimagefieldfile`: A VersatileImageFieldFile instance 111 | """ 112 | versatileimagefieldfile.create_on_demand = True 113 | try: 114 | url = get_url_from_image_key(versatileimagefieldfile, size_key) 115 | except Exception: # pragma: no cover 116 | success = False 117 | url_or_filepath = versatileimagefieldfile.name 118 | logger.exception('Thumbnail generation failed', 119 | extra={'path': url_or_filepath}) 120 | else: 121 | success = True 122 | url_or_filepath = url 123 | return (success, url_or_filepath) 124 | 125 | def warm(self): 126 | """ 127 | Returns a 2-tuple: 128 | [0]: Number of images successfully pre-warmed 129 | [1]: A list of paths on the storage class associated with the 130 | VersatileImageField field being processed by `self` of 131 | files that could not be successfully seeded. 132 | """ 133 | num_images_pre_warmed = 0 134 | failed_to_create_image_path_list = [] 135 | total = self.queryset.count() * len(self.size_key_list) 136 | for a, instance in enumerate(self.queryset, start=1): 137 | for b, size_key in enumerate(self.size_key_list, start=1): 138 | success, url_or_filepath = self._prewarm_versatileimagefield( 139 | size_key, 140 | reduce(getattr, self.image_attr.split("."), instance) 141 | ) 142 | if success is True: 143 | num_images_pre_warmed += 1 144 | if self.verbose: 145 | cli_progress_bar(num_images_pre_warmed, total) 146 | else: # pragma: no cover 147 | failed_to_create_image_path_list.append(url_or_filepath) 148 | 149 | if a * b == total and self.verbose: 150 | stdout.write('\n') 151 | 152 | if self.verbose: 153 | stdout.flush() 154 | return (num_images_pre_warmed, failed_to_create_image_path_list) 155 | -------------------------------------------------------------------------------- /versatileimagefield/mixins.py: -------------------------------------------------------------------------------- 1 | """versatileimagefield Field mixins.""" 2 | import os 3 | import re 4 | 5 | from .datastructures import FilterLibrary 6 | from .registry import autodiscover, versatileimagefield_registry 7 | from .settings import ( 8 | cache, 9 | VERSATILEIMAGEFIELD_CREATE_ON_DEMAND, 10 | VERSATILEIMAGEFIELD_SIZED_DIRNAME, 11 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME 12 | ) 13 | from .validators import validate_ppoi 14 | 15 | autodiscover() 16 | 17 | filter_regex_snippet = r'__({registered_filters})__'.format( 18 | registered_filters='|'.join([ 19 | key 20 | for key, filter_cls in versatileimagefield_registry._filter_registry.items() 21 | ]) 22 | ) 23 | sizer_regex_snippet = r'-({registered_sizers})-(\d+)x(\d+)(?:-\d+)?'.format( 24 | registered_sizers='|'.join([ 25 | sizer_cls.get_filename_key_regex() 26 | for key, sizer_cls in versatileimagefield_registry._sizedimage_registry.items() 27 | ]) 28 | ) 29 | filter_regex = re.compile(filter_regex_snippet + '$') 30 | sizer_regex = re.compile(sizer_regex_snippet + '$') 31 | filter_and_sizer_regex = re.compile( 32 | filter_regex_snippet + sizer_regex_snippet + '$' 33 | ) 34 | 35 | 36 | class VersatileImageMixIn(object): 37 | """A mix-in that provides the filtering/sizing API.""" 38 | 39 | def __init__(self, *args, **kwargs): 40 | """Construct PPOI and create_on_demand.""" 41 | self._create_on_demand = VERSATILEIMAGEFIELD_CREATE_ON_DEMAND 42 | super(VersatileImageMixIn, self).__init__(*args, **kwargs) 43 | self._ppoi_value = (0.5, 0.5) 44 | # Setting initial ppoi 45 | if self.field.ppoi_field: 46 | instance_ppoi_value = getattr( 47 | self.instance, 48 | self.field.ppoi_field, 49 | (0.5, 0.5) 50 | ) 51 | self.ppoi = instance_ppoi_value 52 | 53 | @property 54 | def url(self): 55 | """ 56 | Return the appropriate URL. 57 | 58 | URL is constructed based on these field conditions: 59 | * If empty (not `self.name`) and a placeholder is defined, the 60 | URL to the placeholder is returned. 61 | * Otherwise, defaults to vanilla ImageFieldFile behavior. 62 | """ 63 | if not self.name and self.field.placeholder_image_name: 64 | return self.storage.url(self.field.placeholder_image_name) 65 | 66 | return super(VersatileImageMixIn, self).url 67 | 68 | @property 69 | def create_on_demand(self): 70 | """create_on_demand getter.""" 71 | return self._create_on_demand 72 | 73 | @create_on_demand.setter 74 | def create_on_demand(self, value): 75 | if not isinstance(value, bool): 76 | raise ValueError( 77 | "`create_on_demand` must be a boolean" 78 | ) 79 | else: 80 | self._create_on_demand = value 81 | self.build_filters_and_sizers(self.ppoi, value) 82 | 83 | @property 84 | def ppoi(self): 85 | """Primary Point of Interest (ppoi) getter.""" 86 | return self._ppoi_value 87 | 88 | @ppoi.setter 89 | def ppoi(self, value): 90 | """Primary Point of Interest (ppoi) setter.""" 91 | ppoi = validate_ppoi( 92 | value, 93 | return_converted_tuple=True 94 | ) 95 | if ppoi is not False: 96 | self._ppoi_value = ppoi 97 | self.build_filters_and_sizers(ppoi, self.create_on_demand) 98 | 99 | def build_filters_and_sizers(self, ppoi_value, create_on_demand): 100 | """Build the filters and sizers for a field.""" 101 | name = self.name 102 | if not name and self.field.placeholder_image_name: 103 | name = self.field.placeholder_image_name 104 | self.filters = FilterLibrary( 105 | name, 106 | self.storage, 107 | versatileimagefield_registry, 108 | ppoi_value, 109 | create_on_demand 110 | ) 111 | for ( 112 | attr_name, 113 | sizedimage_cls 114 | ) in versatileimagefield_registry._sizedimage_registry.items(): 115 | setattr( 116 | self, 117 | attr_name, 118 | sizedimage_cls( 119 | path_to_image=name, 120 | storage=self.storage, 121 | create_on_demand=create_on_demand, 122 | ppoi=ppoi_value 123 | ) 124 | ) 125 | 126 | def get_filtered_root_folder(self): 127 | """Return the location where filtered images are stored.""" 128 | folder, filename = os.path.split(self.name) 129 | return os.path.join(folder, VERSATILEIMAGEFIELD_FILTERED_DIRNAME, '') 130 | 131 | def get_sized_root_folder(self): 132 | """Return the location where sized images are stored.""" 133 | folder, filename = os.path.split(self.name) 134 | return os.path.join(VERSATILEIMAGEFIELD_SIZED_DIRNAME, folder, '') 135 | 136 | def get_filtered_sized_root_folder(self): 137 | """Return the location where filtered + sized images are stored.""" 138 | sized_root_folder = self.get_sized_root_folder() 139 | return os.path.join( 140 | sized_root_folder, 141 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME 142 | ) 143 | 144 | def delete_matching_files_from_storage(self, root_folder, regex): 145 | """ 146 | Delete files in `root_folder` which match `regex` before file ext. 147 | 148 | Example values: 149 | * root_folder = 'foo/' 150 | * self.name = 'bar.jpg' 151 | * regex = re.compile('-baz') 152 | 153 | Result: 154 | * foo/bar-baz.jpg <- Deleted 155 | * foo/bar-biz.jpg <- Not deleted 156 | """ 157 | if not self.name: # pragma: no cover 158 | return 159 | try: 160 | directory_list, file_list = self.storage.listdir(root_folder) 161 | except OSError: # pragma: no cover 162 | pass 163 | else: 164 | folder, filename = os.path.split(self.name) 165 | basename, ext = os.path.splitext(filename) 166 | for f in file_list: 167 | if not f.startswith(basename) or not f.endswith(ext): # pragma: no cover 168 | continue 169 | tag = f[len(basename):-len(ext)] 170 | assert f == basename + tag + ext 171 | if regex.match(tag) is not None: 172 | file_location = os.path.join(root_folder, f) 173 | self.storage.delete(file_location) 174 | cache.delete( 175 | self.storage.url(file_location) 176 | ) 177 | print( 178 | "Deleted {file} (created from: {original})".format( 179 | file=os.path.join(root_folder, f), 180 | original=self.name 181 | ) 182 | ) 183 | 184 | def delete_filtered_images(self): 185 | """Delete all filtered images created from `self.name`.""" 186 | self.delete_matching_files_from_storage( 187 | self.get_filtered_root_folder(), 188 | filter_regex 189 | ) 190 | 191 | def delete_sized_images(self): 192 | """Delete all sized images created from `self.name`.""" 193 | self.delete_matching_files_from_storage( 194 | self.get_sized_root_folder(), 195 | sizer_regex 196 | ) 197 | 198 | def delete_filtered_sized_images(self): 199 | """Delete all filtered sized images created from `self.name`.""" 200 | self.delete_matching_files_from_storage( 201 | self.get_filtered_sized_root_folder(), 202 | filter_and_sizer_regex 203 | ) 204 | 205 | def delete_all_created_images(self): 206 | """Delete all images created from `self.name`.""" 207 | self.delete_filtered_images() 208 | self.delete_sized_images() 209 | self.delete_filtered_sized_images() 210 | -------------------------------------------------------------------------------- /versatileimagefield/placeholder.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.base import ContentFile 4 | from django.core.files.storage import default_storage 5 | 6 | empty = object() 7 | 8 | 9 | class PlaceholderImage(object): 10 | """ 11 | A class for configuring images to be used as 'placeholders' for 12 | blank/empty VersatileImageField fields. 13 | """ 14 | 15 | _image_data = empty 16 | 17 | def setup(self): 18 | if isinstance(self.file, ContentFile): 19 | image_data = self.file 20 | else: 21 | image_data = ContentFile(self.file.read(), name=self.name) 22 | self._image_data = image_data 23 | self.file.close() 24 | 25 | @property 26 | def image_data(self): 27 | if self._image_data is empty: 28 | self.setup() 29 | return self._image_data 30 | 31 | 32 | class OnDiscPlaceholderImage(PlaceholderImage): 33 | """ 34 | A placeholder image saved to the same disc as the running 35 | application. 36 | """ 37 | 38 | def __init__(self, path): 39 | """ 40 | `path` - An absolute path to an on-disc image. 41 | """ 42 | self.path = path 43 | 44 | def setup(self): 45 | folder, name = os.path.split(self.path) 46 | with open(self.path, 'rb') as file: 47 | content_file = ContentFile(file.read(), name=name) 48 | self.file = content_file 49 | self.name = name 50 | super(OnDiscPlaceholderImage, self).setup() 51 | 52 | 53 | class OnStoragePlaceholderImage(PlaceholderImage): 54 | """ 55 | A placeholder saved to a storage class. Does not necessarily need to 56 | be on the same storage as the field it is associated with. 57 | """ 58 | 59 | def __init__(self, path, storage=None): 60 | """ 61 | `path` - A path on `storage` to an Image. 62 | `storage` - A django storage class. 63 | """ 64 | self.path = path 65 | self.storage = storage 66 | 67 | def setup(self): 68 | storage = self.storage or default_storage 69 | file = storage.open(self.path) 70 | folder, name = os.path.split(self.path) 71 | self.file = file 72 | self.name = name 73 | super(OnStoragePlaceholderImage, self).setup() 74 | -------------------------------------------------------------------------------- /versatileimagefield/processors/__init__.py: -------------------------------------------------------------------------------- 1 | from .hashlib_processors import md5, md5_16 2 | 3 | __all__ = [ 4 | "md5", 5 | "md5_16" 6 | ] 7 | -------------------------------------------------------------------------------- /versatileimagefield/processors/hashlib_processors.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def md5(image_key): 5 | """Return the md5 hash of image_key.""" 6 | return hashlib.md5(image_key.encode('utf-8')).hexdigest() 7 | 8 | 9 | def md5_16(image_key): 10 | """Return the first 16 characters of the md5 hash of image_key.""" 11 | return md5(image_key)[:16] 12 | -------------------------------------------------------------------------------- /versatileimagefield/registry.py: -------------------------------------------------------------------------------- 1 | """Registry.""" 2 | import copy 3 | 4 | from .datastructures import FilteredImage, SizedImage 5 | 6 | 7 | class AlreadyRegistered(Exception): 8 | """Already registered exception.""" 9 | 10 | pass 11 | 12 | 13 | class InvalidSizedImageSubclass(Exception): 14 | """Ivalid sized image subclass exception.""" 15 | 16 | pass 17 | 18 | 19 | class InvalidFilteredImageSubclass(Exception): 20 | """Invalid filtered image subclass exception.""" 21 | 22 | pass 23 | 24 | 25 | class NotRegistered(Exception): 26 | """Not registered sizer/filter exception.""" 27 | 28 | pass 29 | 30 | 31 | class UnallowedSizerName(Exception): 32 | """Unallowed sizer name exception.""" 33 | 34 | pass 35 | 36 | 37 | class UnallowedFilterName(Exception): 38 | """Unallowed filter name exception.""" 39 | 40 | pass 41 | 42 | 43 | class VersatileImageFieldRegistry(object): 44 | """ 45 | A VersatileImageFieldRegistry object. 46 | 47 | Allows new SizedImage & FilteredImage subclasses to be dynamically added 48 | to all SizedImageFileField instances at runtime. New SizedImage subclasses 49 | are registered with the register_sizer method. New ProcessedImage 50 | subclasses are registered with the register_filter method. 51 | """ 52 | 53 | unallowed_sizer_names = ( 54 | 'build_filters_and_sizers', 55 | 'chunks', 56 | 'close', 57 | 'closed', 58 | 'create_on_demand', 59 | 'delete', 60 | 'encoding', 61 | 'field', 62 | 'file', 63 | 'fileno', 64 | 'filters', 65 | 'flush', 66 | 'get_filtered_root_folder', 67 | 'get_sized_root_folder', 68 | 'get_filtered_sized_root_folder', 69 | 'delete_matching_files_from_storage', 70 | 'delete_filtered_images', 71 | 'delete_sized_images', 72 | 'delete_filtered_sized_images', 73 | 'delete_all_created_images', 74 | 'height', 75 | 'instance', 76 | 'isatty', 77 | 'multiple_chunks', 78 | 'name', 79 | 'newlines', 80 | 'open', 81 | 'path', 82 | 'ppoi', 83 | 'read', 84 | 'readinto', 85 | 'readline', 86 | 'readlines', 87 | 'save', 88 | 'seek', 89 | 'size', 90 | 'softspace', 91 | 'storage', 92 | 'tell', 93 | 'truncate', 94 | 'url', 95 | 'validate_ppoi', 96 | 'width', 97 | 'write', 98 | 'writelines', 99 | 'xreadlines' 100 | ) 101 | 102 | def __init__(self, name='versatileimage_registry'): 103 | """Initialize a registry.""" 104 | self._sizedimage_registry = {} # attr_name -> sizedimage_cls 105 | self._filter_registry = {} # attr_name -> filter_cls 106 | self.name = name 107 | 108 | def register_sizer(self, attr_name, sizedimage_cls): 109 | """ 110 | Register a new SizedImage subclass (`sizedimage_cls`). 111 | 112 | To be used via the attribute (`attr_name`). 113 | """ 114 | if attr_name.startswith( 115 | '_' 116 | ) or attr_name in self.unallowed_sizer_names: 117 | raise UnallowedSizerName( 118 | "`%s` is an unallowed Sizer name. Sizer names cannot begin " 119 | "with an underscore or be named any of the " 120 | "following: %s." % ( 121 | attr_name, 122 | ', '.join([ 123 | name 124 | for name in self.unallowed_sizer_names 125 | ]) 126 | ) 127 | ) 128 | if not issubclass(sizedimage_cls, SizedImage): 129 | raise InvalidSizedImageSubclass( 130 | 'Only subclasses of versatileimagefield.datastructures.' 131 | 'SizedImage may be registered with register_sizer' 132 | ) 133 | 134 | if attr_name in self._sizedimage_registry: 135 | raise AlreadyRegistered( 136 | 'A SizedImage class is already registered to the `%s` ' 137 | 'attribute. If you would like to override this attribute, ' 138 | 'use the unregister method' % attr_name 139 | ) 140 | else: 141 | self._sizedimage_registry[attr_name] = sizedimage_cls 142 | 143 | def unregister_sizer(self, attr_name): 144 | """ 145 | Unregister the SizedImage subclass currently assigned to `attr_name`. 146 | 147 | If a SizedImage subclass isn't already registered to `attr_name` 148 | NotRegistered will raise. 149 | """ 150 | if attr_name not in self._sizedimage_registry: 151 | raise NotRegistered( 152 | 'No SizedImage subclass is registered to %s' % attr_name 153 | ) 154 | else: 155 | del self._sizedimage_registry[attr_name] 156 | 157 | def register_filter(self, attr_name, filterimage_cls): 158 | """ 159 | Register a new FilteredImage subclass (`filterimage_cls`). 160 | 161 | To be used via the attribute (filters.`attr_name`) 162 | """ 163 | if attr_name.startswith('_'): 164 | raise UnallowedFilterName( 165 | '`%s` is an unallowed Filter name. Filter names cannot begin ' 166 | 'with an underscore.' % attr_name 167 | ) 168 | if not issubclass(filterimage_cls, FilteredImage): 169 | raise InvalidFilteredImageSubclass( 170 | 'Only subclasses of FilteredImage may be registered as ' 171 | 'filters with VersatileImageFieldRegistry' 172 | ) 173 | 174 | if attr_name in self._filter_registry: 175 | raise AlreadyRegistered( 176 | 'A ProcessedImageMixIn class is already registered to the `%s`' 177 | ' attribute. If you would like to override this attribute, ' 178 | 'use the unregister method' % attr_name 179 | ) 180 | else: 181 | self._filter_registry[attr_name] = filterimage_cls 182 | 183 | def unregister_filter(self, attr_name): 184 | """ 185 | Unregister the FilteredImage subclass currently assigned to attr_name. 186 | 187 | If a FilteredImage subclass isn't already registered to filters. 188 | `attr_name` NotRegistered will raise. 189 | """ 190 | if attr_name not in self._filter_registry: 191 | raise NotRegistered( 192 | 'No FilteredImage subclass is registered to %s' % attr_name 193 | ) 194 | else: 195 | del self._filter_registry[attr_name] 196 | 197 | 198 | versatileimagefield_registry = VersatileImageFieldRegistry() 199 | 200 | 201 | def autodiscover(): 202 | """ 203 | Discover versatileimagefield.py modules. 204 | 205 | Iterate over django.apps.get_app_configs() and discover 206 | versatileimagefield.py modules. 207 | """ 208 | from importlib import import_module 209 | from django.apps import apps 210 | from django.utils.module_loading import module_has_submodule 211 | 212 | for app_config in apps.get_app_configs(): 213 | # Attempt to import the app's module. 214 | 215 | try: 216 | before_import_sizedimage_registry = copy.copy( 217 | versatileimagefield_registry._sizedimage_registry 218 | ) 219 | before_import_filter_registry = copy.copy( 220 | versatileimagefield_registry._filter_registry 221 | ) 222 | import_module('%s.versatileimagefield' % app_config.name) 223 | except Exception: 224 | # Reset the versatileimagefield_registry to the state before the 225 | # last import as this import will have to reoccur on the next 226 | # request and this could raise NotRegistered and AlreadyRegistered 227 | # exceptions (see django ticket #8245). 228 | versatileimagefield_registry._sizedimage_registry = \ 229 | before_import_sizedimage_registry 230 | versatileimagefield_registry._filter_registry = \ 231 | before_import_filter_registry 232 | 233 | # Decide whether to bubble up this error. If the app just 234 | # doesn't have the module in question, we can ignore the error 235 | # attempting to import it, otherwise we want it to bubble up. 236 | if module_has_submodule(app_config.module, 'versatileimagefield'): 237 | raise 238 | -------------------------------------------------------------------------------- /versatileimagefield/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.serializers import ImageField 2 | 3 | from .utils import ( 4 | build_versatileimagefield_url_set, 5 | get_rendition_key_set, 6 | validate_versatileimagefield_sizekey_list 7 | ) 8 | 9 | 10 | class VersatileImageFieldSerializer(ImageField): 11 | """ 12 | Returns a dictionary of urls corresponding to self.sizes 13 | - `image_instance`: A VersatileImageFieldFile instance 14 | - `self.sizes`: An iterable of 2-tuples, both strings. Example: 15 | [ 16 | ('large', 'url'), 17 | ('medium', 'crop__400x400'), 18 | ('small', 'thumbnail__100x100') 19 | ] 20 | 21 | The above would lead to the following response: 22 | { 23 | 'large': 'http://some.url/image.jpg', 24 | 'medium': 'http://some.url/__sized__/image-crop-400x400.jpg', 25 | 'small': 'http://some.url/__sized__/image-thumbnail-100x100.jpg', 26 | } 27 | """ 28 | read_only = True 29 | 30 | def __init__(self, sizes, *args, **kwargs): 31 | if isinstance(sizes, str): 32 | sizes = get_rendition_key_set(sizes) 33 | self.sizes = validate_versatileimagefield_sizekey_list(sizes) 34 | super(VersatileImageFieldSerializer, self).__init__( 35 | *args, **kwargs 36 | ) 37 | 38 | def to_native(self, value): 39 | """For djangorestframework <=2.3.14""" 40 | context_request = None 41 | if self.context: 42 | context_request = self.context.get('request', None) 43 | return build_versatileimagefield_url_set( 44 | value, 45 | self.sizes, 46 | request=context_request 47 | ) 48 | 49 | def to_representation(self, value): 50 | """ 51 | For djangorestframework >= 3 52 | """ 53 | return self.to_native(value) 54 | -------------------------------------------------------------------------------- /versatileimagefield/settings.py: -------------------------------------------------------------------------------- 1 | """versatileimagefield settings.""" 2 | from django.utils.module_loading import import_string 3 | 4 | from django.conf import settings 5 | from django.core.cache import ( 6 | caches, 7 | cache as default_cache, 8 | InvalidCacheBackendError 9 | ) 10 | from django.core.exceptions import ImproperlyConfigured 11 | 12 | # Defaults 13 | QUAL = 70 14 | VERSATILEIMAGEFIELD_CACHE_LENGTH = 2592000 15 | VERSATILEIMAGEFIELD_CACHE_NAME = 'versatileimagefield_cache' 16 | VERSATILEIMAGEFIELD_SIZED_DIRNAME = '__sized__' 17 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME = '__filtered__' 18 | VERSATILEIMAGEFIELD_PLACEHOLDER_DIRNAME = '__placeholder__' 19 | VERSATILEIMAGEFIELD_CREATE_ON_DEMAND = True 20 | 21 | VERSATILEIMAGEFIELD_SETTINGS = { 22 | # The amount of time, in seconds, that references to created images 23 | # should be stored in the cache. Defaults to `2592000` (30 days) 24 | 'cache_length': VERSATILEIMAGEFIELD_CACHE_LENGTH, 25 | # The name of the cache you'd like `django-versatileimagefield` to use. 26 | # Defaults to 'versatileimagefield_cache'. If no cache exists to the name 27 | # provided, the 'default' cache will be used. 28 | 'cache_name': VERSATILEIMAGEFIELD_CACHE_NAME, 29 | # The save quality of modified JPEG images. More info here: 30 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#jpeg 31 | # Defaults to 70 32 | 'jpeg_resize_quality': QUAL, 33 | # The save quality of modified WEBP images. More info here: 34 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 35 | # Defaults to 70 36 | 'webp_resize_quality': QUAL, 37 | # If true, instructs the WebP writer to use lossless compression. 38 | # https://pillow.readthedocs.io/en/latest/handbook/image-file-formats.html#webp 39 | # Defaults to False 40 | 'lossless_webp': False, 41 | # The name of the top-level folder within your storage to save all 42 | # sized images. Defaults to '__sized__' 43 | 'sized_directory_name': VERSATILEIMAGEFIELD_SIZED_DIRNAME, 44 | # The name of the directory to save all filtered images within. 45 | # Defaults to '__filtered__': 46 | 'filtered_directory_name': VERSATILEIMAGEFIELD_FILTERED_DIRNAME, 47 | # The name of the directory to save placeholder images within. 48 | # Defaults to '__placeholder__': 49 | 'placeholder_directory_name': VERSATILEIMAGEFIELD_PLACEHOLDER_DIRNAME, 50 | # Whether or not to create new images on-the-fly. Set this to `False` for 51 | # speedy performance but don't forget to 'pre-warm' to ensure they're 52 | # created and available at the appropriate URL. 53 | 'create_images_on_demand': VERSATILEIMAGEFIELD_CREATE_ON_DEMAND, 54 | # A dot-notated python path string to a function that processes sized 55 | # image keys. Typically used to md5-ify the 'image key' portion of the 56 | # filename, giving each a uniform length. 57 | # `django-versatileimagefield` ships with two post processors: 58 | # 1. 'versatileimagefield.processors.md5' Returns a full length (32 char) 59 | # md5 hash of `image_key`. 60 | # 2. 'versatileimagefield.processors.md5_16' Returns the first 16 chars 61 | # of the 32 character md5 hash of `image_key`. 62 | # By default, image_keys are unprocessed. To write your own processor, 63 | # just define a function (that can be imported from your project's 64 | # python path) that takes a single argument, `image_key` and returns 65 | # a string. 66 | 'image_key_post_processor': None, 67 | # Whether to create progressive JPEGs. Read more about progressive JPEGs 68 | # here: https://optimus.io/support/progressive-jpeg/ 69 | 'progressive_jpeg': False 70 | } 71 | 72 | USER_DEFINED = getattr( 73 | settings, 74 | 'VERSATILEIMAGEFIELD_SETTINGS', 75 | None 76 | ) 77 | 78 | if USER_DEFINED: 79 | VERSATILEIMAGEFIELD_SETTINGS.update(USER_DEFINED) 80 | 81 | JPEG_QUAL = VERSATILEIMAGEFIELD_SETTINGS.get('jpeg_resize_quality') 82 | WEBP_QUAL = VERSATILEIMAGEFIELD_SETTINGS.get('webp_resize_quality') 83 | 84 | VERSATILEIMAGEFIELD_CACHE_NAME = VERSATILEIMAGEFIELD_SETTINGS.get( 85 | 'cache_name' 86 | ) 87 | 88 | try: 89 | cache = caches[VERSATILEIMAGEFIELD_CACHE_NAME] 90 | except InvalidCacheBackendError: 91 | cache = default_cache 92 | 93 | VERSATILEIMAGEFIELD_CACHE_LENGTH = VERSATILEIMAGEFIELD_SETTINGS.get( 94 | 'cache_length' 95 | ) 96 | 97 | VERSATILEIMAGEFIELD_SIZED_DIRNAME = VERSATILEIMAGEFIELD_SETTINGS.get( 98 | 'sized_directory_name' 99 | ) 100 | 101 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME = VERSATILEIMAGEFIELD_SETTINGS.get( 102 | 'filtered_directory_name' 103 | ) 104 | 105 | VERSATILEIMAGEFIELD_PLACEHOLDER_DIRNAME = VERSATILEIMAGEFIELD_SETTINGS.get( 106 | 'placeholder_directory_name' 107 | ) 108 | 109 | VERSATILEIMAGEFIELD_CREATE_ON_DEMAND = VERSATILEIMAGEFIELD_SETTINGS.get( 110 | 'create_images_on_demand' 111 | ) 112 | 113 | VERSATILEIMAGEFIELD_PROGRESSIVE_JPEG = VERSATILEIMAGEFIELD_SETTINGS.get( 114 | 'progressive_jpeg' 115 | ) 116 | 117 | VERSATILEIMAGEFIELD_LOSSLESS_WEBP = VERSATILEIMAGEFIELD_SETTINGS.get( 118 | 'lossless_webp' 119 | ) 120 | 121 | IMAGE_SETS = getattr(settings, 'VERSATILEIMAGEFIELD_RENDITION_KEY_SETS', {}) 122 | 123 | post_processor_string = VERSATILEIMAGEFIELD_SETTINGS.get( 124 | 'image_key_post_processor', 125 | None 126 | ) 127 | 128 | if post_processor_string is not None: 129 | try: 130 | VERSATILEIMAGEFIELD_POST_PROCESSOR = import_string( 131 | post_processor_string 132 | ) 133 | except ImportError: # pragma: no cover 134 | raise ImproperlyConfigured( 135 | "VERSATILEIMAGEFIELD_SETTINGS['image_key_post_processor'] is set " 136 | "incorrectly. {} could not be imported.".format( 137 | post_processor_string 138 | ) 139 | ) 140 | else: 141 | VERSATILEIMAGEFIELD_POST_PROCESSOR = None 142 | -------------------------------------------------------------------------------- /versatileimagefield/static/versatileimagefield/css/versatileimagefield-bootstrap3.css: -------------------------------------------------------------------------------- 1 | div.versatileimagefield div.form-group label { 2 | display:block; 3 | font-weight:normal; 4 | } 5 | -------------------------------------------------------------------------------- /versatileimagefield/static/versatileimagefield/css/versatileimagefield-djangoadmin.css: -------------------------------------------------------------------------------- 1 | div.versatileimagefield { 2 | float:left; 3 | clear:right; 4 | display:block; 5 | min-width:350px; 6 | } 7 | 8 | label.versatileimagefield-label { 9 | display:inline; 10 | float:left; 11 | clear:both; 12 | font-size:1em; 13 | padding-top:0px; 14 | } 15 | 16 | div.sizedimage-mod { 17 | margin:15px 0; 18 | } 19 | 20 | div.image-wrap.outer { 21 | border:4px solid #CCCCCC; 22 | } 23 | 24 | div.sizedimage-mod:first-child { 25 | margin-top:0; 26 | } 27 | div.versatileimagefield input[type='file'] { 28 | margin-top:0px; 29 | margin-bottom:0px; 30 | padding-top:0px; 31 | padding-bottom:0px; 32 | } 33 | -------------------------------------------------------------------------------- /versatileimagefield/static/versatileimagefield/css/versatileimagefield.css: -------------------------------------------------------------------------------- 1 | div.image-wrap { 2 | display:inline-block; 3 | } 4 | 5 | div.point-stage { 6 | position:absolute; 7 | } 8 | 9 | div.ppoi-point { 10 | width:8px; 11 | height:8px; 12 | background: rgb(255, 0, 0); 13 | background: rgba(255, 0, 0, .5); 14 | position:absolute; 15 | border:1px solid #FFFFFF; 16 | } 17 | -------------------------------------------------------------------------------- /versatileimagefield/static/versatileimagefield/js/versatileimagefield.js: -------------------------------------------------------------------------------- 1 | function generateCenterpointWidget(){ 2 | // Find all images with the class 'sizedimage-preview' 3 | var crops = document.getElementsByClassName('sizedimage-preview') 4 | // Iterate through those images 5 | for (var x=0; x 4 | 5 | {{ widget.value }} 6 | 7 | {% if not widget.required %} 8 |
9 | 10 | 13 |
14 | {% endif %} 15 | {% endif %} 16 |
17 | 18 |
19 |
21 |
22 |
23 | {% if widget.value and widget.sized_url %} 24 |
25 | 29 |
30 | {% endif %} 31 |
32 |
33 |
34 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /versatileimagefield/templates/versatileimagefield/forms/widgets/versatile_image_bootstrap.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if widget.is_initial %} 3 |
4 | 5 | {{ widget.value }} 6 |
7 | {% if not widget.required %} 8 |
9 | 10 | 13 |
14 | {% endif %} 15 | {% endif %} 16 |
17 | 18 |
19 |
21 |
22 |
23 | {% if widget.value and widget.sized_url %} 24 |
25 | 29 |
30 | {% endif %} 31 |
32 |
33 |
34 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /versatileimagefield/utils.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | 3 | import os 4 | 5 | import magic 6 | from django.core.exceptions import ImproperlyConfigured 7 | 8 | from .settings import ( 9 | IMAGE_SETS, 10 | JPEG_QUAL, 11 | VERSATILEIMAGEFIELD_POST_PROCESSOR, 12 | VERSATILEIMAGEFIELD_SIZED_DIRNAME, 13 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME, 14 | WEBP_QUAL, 15 | ) 16 | 17 | # PIL-supported file formats as found here: 18 | # https://infohost.nmt.edu/tcc/help/pubs/pil/formats.html 19 | # {mime type: PIL Identifier} 20 | MIME_TYPE_TO_PIL_IDENTIFIER = { 21 | 'image/x-ms-bmp': 'BMP', 22 | 'image/bmp': 'BMP', 23 | 'image/dcx': 'DCX', 24 | 'image/eps': 'eps', 25 | 'image/gif': 'GIF', 26 | 'image/jpeg': 'JPEG', 27 | 'image/pcd': 'PCD', 28 | 'image/pcx': 'PCX', 29 | 'application/pdf': 'PDF', 30 | 'image/png': 'PNG', 31 | 'image/x-ppm': 'PPM', 32 | 'image/psd': 'PSD', 33 | 'image/tiff': 'TIFF', 34 | 'image/x-xbitmap': 'XBM', 35 | 'image/x-xpm': 'XPM', 36 | 'image/webp': 'WEBP', 37 | } 38 | 39 | 40 | class InvalidSizeKeySet(Exception): 41 | pass 42 | 43 | 44 | class InvalidSizeKey(Exception): 45 | pass 46 | 47 | 48 | def post_process_image_key(image_key): 49 | """Apply the processor function associated with settings.VER""" 50 | if VERSATILEIMAGEFIELD_POST_PROCESSOR is None: 51 | return image_key 52 | else: 53 | return VERSATILEIMAGEFIELD_POST_PROCESSOR(image_key) 54 | 55 | 56 | def get_resized_filename(filename, width, height, filename_key): 57 | """ 58 | Return the 'resized filename' (according to `width`, `height` and 59 | `filename_key`) in the following format: 60 | `filename`-`filename_key`-`width`x`height`.ext 61 | """ 62 | try: 63 | image_name, ext = filename.rsplit('.', 1) 64 | except ValueError: 65 | image_name = filename 66 | ext = 'jpg' 67 | 68 | resized_template = "%(filename_key)s-%(width)dx%(height)d" 69 | quality = WEBP_QUAL if ext.lower() == 'webp' else JPEG_QUAL 70 | if ext.lower() in ['jpg', 'jpeg', 'webp']: 71 | resized_template = resized_template + "-%(quality)d" 72 | 73 | resized_key = resized_template % ({ 74 | 'filename_key': filename_key, 75 | 'width': width, 76 | 'height': height, 77 | 'quality': quality 78 | }) 79 | 80 | return "%(image_name)s-%(image_key)s.%(ext)s" % ({ 81 | 'image_name': image_name, 82 | 'image_key': post_process_image_key(resized_key), 83 | 'ext': ext 84 | }) 85 | 86 | 87 | def get_resized_path(path_to_image, width, height, 88 | filename_key, storage): 89 | """ 90 | Return a `path_to_image` location on `storage` as dictated by `width`, `height` 91 | and `filename_key` 92 | """ 93 | containing_folder, filename = os.path.split(path_to_image) 94 | 95 | resized_filename = get_resized_filename( 96 | filename, 97 | width, 98 | height, 99 | filename_key 100 | ) 101 | 102 | joined_path = os.path.join(*[ 103 | VERSATILEIMAGEFIELD_SIZED_DIRNAME, 104 | containing_folder, 105 | resized_filename 106 | ]).replace(' ', '') # Removing spaces so this path is memcached friendly 107 | 108 | return joined_path 109 | 110 | 111 | def get_filtered_filename(filename, filename_key): 112 | """ 113 | Return the 'filtered filename' (according to `filename_key`) 114 | in the following format: 115 | `filename`__`filename_key`__.ext 116 | """ 117 | try: 118 | image_name, ext = filename.rsplit('.', 1) 119 | except ValueError: 120 | image_name = filename 121 | ext = 'jpg' 122 | return "%(image_name)s__%(filename_key)s__.%(ext)s" % ({ 123 | 'image_name': image_name, 124 | 'filename_key': filename_key, 125 | 'ext': ext 126 | }) 127 | 128 | 129 | def get_filtered_path(path_to_image, filename_key, storage): 130 | """ 131 | Return the 'filtered path' 132 | """ 133 | containing_folder, filename = os.path.split(path_to_image) 134 | 135 | filtered_filename = get_filtered_filename(filename, filename_key) 136 | path_to_return = os.path.join(*[ 137 | containing_folder, 138 | VERSATILEIMAGEFIELD_FILTERED_DIRNAME, 139 | filtered_filename 140 | ]) 141 | # Removing spaces so this path is memcached key friendly 142 | path_to_return = path_to_return.replace(' ', '') 143 | return path_to_return 144 | 145 | 146 | def get_image_metadata_from_file(file_like): 147 | """ 148 | Receive a valid image file and returns a 2-tuple of two strings: 149 | [0]: Image format (i.e. 'jpg', 'gif' or 'png') 150 | [1]: InMemoryUploadedFile-friendly save format (i.e. 'image/jpeg') 151 | image_format, in_memory_file_type 152 | """ 153 | mime_type = magic.from_buffer(file_like.read(1024), mime=True) 154 | file_like.seek(0) 155 | image_format = MIME_TYPE_TO_PIL_IDENTIFIER[mime_type] 156 | return image_format, mime_type 157 | 158 | 159 | def validate_versatileimagefield_sizekey_list(sizes): 160 | """ 161 | Validate a list of size keys. 162 | 163 | `sizes`: An iterable of 2-tuples, both strings. Example: 164 | [ 165 | ('large', 'url'), 166 | ('medium', 'crop__400x400'), 167 | ('small', 'thumbnail__100x100') 168 | ] 169 | """ 170 | try: 171 | for key, size_key in sizes: 172 | size_key_split = size_key.split('__') 173 | if size_key_split[-1] != 'url' and ( 174 | 'x' not in size_key_split[-1] 175 | ): 176 | raise InvalidSizeKey( 177 | "{0} is an invalid size. All sizes must be either " 178 | "'url' or made up of at least two segments separated " 179 | "by double underscores. Examples: 'crop__400x400', " 180 | "filters__invert__url".format(size_key) 181 | ) 182 | except ValueError: 183 | raise InvalidSizeKeySet( 184 | '{} is an invalid size key set. Size key sets must be an ' 185 | 'iterable of 2-tuples'.format(str(sizes)) 186 | ) 187 | return list(set(sizes)) 188 | 189 | 190 | def get_url_from_image_key(image_instance, image_key): 191 | """Build a URL from `image_key`.""" 192 | img_key_split = image_key.split('__') 193 | if 'x' in img_key_split[-1]: 194 | size_key = img_key_split.pop(-1) 195 | else: 196 | size_key = None 197 | img_url = reduce(getattr, img_key_split, image_instance) 198 | if size_key: 199 | img_url = img_url[size_key].url 200 | return img_url 201 | 202 | 203 | def build_versatileimagefield_url_set(image_instance, size_set, request=None): 204 | """ 205 | Return a dictionary of urls corresponding to size_set 206 | - `image_instance`: A VersatileImageFieldFile 207 | - `size_set`: An iterable of 2-tuples, both strings. Example: 208 | [ 209 | ('large', 'url'), 210 | ('medium', 'crop__400x400'), 211 | ('small', 'thumbnail__100x100') 212 | ] 213 | 214 | The above would lead to the following response: 215 | { 216 | 'large': 'http://some.url/image.jpg', 217 | 'medium': 'http://some.url/__sized__/image-crop-400x400.jpg', 218 | 'small': 'http://some.url/__sized__/image-thumbnail-100x100.jpg', 219 | } 220 | - `request`: 221 | """ 222 | size_set = validate_versatileimagefield_sizekey_list(size_set) 223 | to_return = {} 224 | if image_instance or image_instance.field.placeholder_image: 225 | for key, image_key in size_set: 226 | img_url = get_url_from_image_key(image_instance, image_key) 227 | if request is not None: 228 | img_url = request.build_absolute_uri(img_url) 229 | to_return[key] = img_url 230 | return to_return 231 | 232 | 233 | def get_rendition_key_set(key): 234 | """ 235 | Retrieve a validated and prepped Rendition Key Set from 236 | settings.VERSATILEIMAGEFIELD_RENDITION_KEY_SETS 237 | """ 238 | try: 239 | rendition_key_set = IMAGE_SETS[key] 240 | except KeyError: 241 | raise ImproperlyConfigured( 242 | "No Rendition Key Set exists at " 243 | "settings.VERSATILEIMAGEFIELD_RENDITION_KEY_SETS['{}']".format(key) 244 | ) 245 | else: 246 | return validate_versatileimagefield_sizekey_list(rendition_key_set) 247 | -------------------------------------------------------------------------------- /versatileimagefield/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | INVALID_CENTERPOINT_ERROR_MESSAGE = ( 4 | "%s is in invalid ppoi. A valid " 5 | "ppoi must provide two coordinates, one for the x axis and one " 6 | "for the y, where both values are between 0 and 1. You may pass it as " 7 | "either a two-position tuple like this: (0.5,0.5) or as a string where " 8 | "the two values are separated by an 'x' like this: '0.5x0.5'." 9 | ) 10 | 11 | 12 | def validate_ppoi_tuple(value): 13 | """ 14 | Validates that a tuple (`value`)... 15 | ...has a len of exactly 2 16 | ...both values are floats/ints that are greater-than-or-equal-to 0 17 | AND less-than-or-equal-to 1 18 | """ 19 | valid = True 20 | while valid is True: 21 | if len(value) == 2 and isinstance(value, tuple): 22 | for x in value: 23 | if x >= 0 and x <= 1: 24 | pass 25 | else: 26 | valid = False 27 | break 28 | else: 29 | valid = False 30 | return valid 31 | 32 | 33 | def validate_ppoi(value, return_converted_tuple=False): 34 | """ 35 | Converts, validates and optionally returns a string with formatting: 36 | '%(x_axis)dx%(y_axis)d' into a two position tuple. 37 | 38 | If a tuple is passed to `value` it is also validated. 39 | 40 | Both x_axis and y_axis must be floats or ints greater 41 | than 0 and less than 1. 42 | """ 43 | 44 | valid_ppoi = True 45 | to_return = None 46 | if isinstance(value, tuple): 47 | valid_ppoi = validate_ppoi_tuple(value) 48 | if valid_ppoi: 49 | to_return = value 50 | else: 51 | tup = tuple() 52 | try: 53 | string_split = [ 54 | float(segment.strip()) 55 | for segment in value.split('x') 56 | if float(segment.strip()) >= 0 and float(segment.strip()) <= 1 57 | ] 58 | except Exception: 59 | valid_ppoi = False 60 | else: 61 | tup = tuple(string_split) 62 | 63 | valid_ppoi = validate_ppoi_tuple(tup) 64 | 65 | if valid_ppoi: 66 | to_return = tup 67 | if not valid_ppoi: 68 | raise ValidationError( 69 | message=INVALID_CENTERPOINT_ERROR_MESSAGE % str(value), 70 | code='invalid_ppoi' 71 | ) 72 | else: 73 | if to_return and return_converted_tuple is True: 74 | return to_return 75 | 76 | 77 | __all__ = ['validate_ppoi_tuple', 'validate_ppoi'] 78 | -------------------------------------------------------------------------------- /versatileimagefield/versatileimagefield.py: -------------------------------------------------------------------------------- 1 | """Default sizer & filter definitions.""" 2 | from io import BytesIO 3 | 4 | from PIL import Image, ImageOps 5 | 6 | from .datastructures import FilteredImage, SizedImage 7 | from .registry import versatileimagefield_registry 8 | 9 | 10 | try: 11 | ANTIALIAS = Image.Resampling.LANCZOS 12 | except AttributeError: 13 | ANTIALIAS = Image.ANTIALIAS # deprecated in 9.1.0 and removed in 10.0.0 14 | 15 | 16 | class CroppedImage(SizedImage): 17 | """ 18 | A SizedImage subclass that creates a 'cropped' image. 19 | 20 | See the `process_image` method for more details. 21 | """ 22 | 23 | filename_key = 'crop' 24 | filename_key_regex = r'crop-c[0-9-]+__[0-9-]+' 25 | 26 | def get_filename_key(self): 27 | """Return the filename key for cropped images.""" 28 | return "{key}-c{ppoi}".format( 29 | key=self.filename_key, 30 | ppoi=self.ppoi_as_str() 31 | ) 32 | 33 | def crop_on_centerpoint(self, image, width, height, ppoi=(0.5, 0.5)): 34 | """ 35 | Return a PIL Image instance cropped from `image`. 36 | 37 | Image has an aspect ratio provided by dividing `width` / `height`), 38 | sized down to `width`x`height`. Any 'excess pixels' are trimmed away 39 | in respect to the pixel of `image` that corresponds to `ppoi` (Primary 40 | Point of Interest). 41 | 42 | `image`: A PIL Image instance 43 | `width`: Integer, width of the image to return (in pixels) 44 | `height`: Integer, height of the image to return (in pixels) 45 | `ppoi`: A 2-tuple of floats with values greater than 0 and less than 1 46 | These values are converted into a cartesian coordinate that 47 | signifies the 'center pixel' which the crop will center on 48 | (to trim the excess from the 'long side'). 49 | 50 | Determines whether to trim away pixels from either the left/right or 51 | top/bottom sides by comparing the aspect ratio of `image` vs the 52 | aspect ratio of `width`x`height`. 53 | 54 | Will trim from the left/right sides if the aspect ratio of `image` 55 | is greater-than-or-equal-to the aspect ratio of `width`x`height`. 56 | 57 | Will trim from the top/bottom sides if the aspect ration of `image` 58 | is less-than the aspect ratio or `width`x`height`. 59 | 60 | Similar to Kevin Cazabon's ImageOps.fit method but uses the 61 | ppoi value as an absolute centerpoint (as opposed as a 62 | percentage to trim off the 'long sides'). 63 | """ 64 | ppoi_x_axis = int(image.size[0] * ppoi[0]) 65 | ppoi_y_axis = int(image.size[1] * ppoi[1]) 66 | center_pixel_coord = (ppoi_x_axis, ppoi_y_axis) 67 | # Calculate the aspect ratio of `image` 68 | orig_aspect_ratio = float( 69 | image.size[0] 70 | ) / float( 71 | image.size[1] 72 | ) 73 | crop_aspect_ratio = float(width) / float(height) 74 | 75 | # Figure out if we're trimming from the left/right or top/bottom 76 | if orig_aspect_ratio >= crop_aspect_ratio: 77 | # `image` is wider than what's needed, 78 | # crop from left/right sides 79 | orig_crop_width = int( 80 | (crop_aspect_ratio * float(image.size[1])) + 0.5 81 | ) 82 | orig_crop_height = image.size[1] 83 | crop_boundary_top = 0 84 | crop_boundary_bottom = orig_crop_height 85 | crop_boundary_left = center_pixel_coord[0] - (orig_crop_width // 2) 86 | crop_boundary_right = crop_boundary_left + orig_crop_width 87 | if crop_boundary_left < 0: 88 | crop_boundary_left = 0 89 | crop_boundary_right = crop_boundary_left + orig_crop_width 90 | elif crop_boundary_right > image.size[0]: 91 | crop_boundary_right = image.size[0] 92 | crop_boundary_left = image.size[0] - orig_crop_width 93 | 94 | else: 95 | # `image` is taller than what's needed, 96 | # crop from top/bottom sides 97 | orig_crop_width = image.size[0] 98 | orig_crop_height = int( 99 | (float(image.size[0]) / crop_aspect_ratio) + 0.5 100 | ) 101 | crop_boundary_left = 0 102 | crop_boundary_right = orig_crop_width 103 | crop_boundary_top = center_pixel_coord[1] - (orig_crop_height // 2) 104 | crop_boundary_bottom = crop_boundary_top + orig_crop_height 105 | if crop_boundary_top < 0: 106 | crop_boundary_top = 0 107 | crop_boundary_bottom = crop_boundary_top + orig_crop_height 108 | elif crop_boundary_bottom > image.size[1]: 109 | crop_boundary_bottom = image.size[1] 110 | crop_boundary_top = image.size[1] - orig_crop_height 111 | # Cropping the image from the original image 112 | cropped_image = image.crop( 113 | ( 114 | crop_boundary_left, 115 | crop_boundary_top, 116 | crop_boundary_right, 117 | crop_boundary_bottom 118 | ) 119 | ) 120 | # Resizing the newly cropped image to the size specified 121 | # (as determined by `width`x`height`) 122 | return cropped_image.resize( 123 | (width, height), 124 | ANTIALIAS 125 | ) 126 | 127 | def process_image(self, image, image_format, save_kwargs, 128 | width, height): 129 | """ 130 | Return a BytesIO instance of `image` cropped to `width` and `height`. 131 | 132 | Cropping will first reduce an image down to its longest side 133 | and then crop inwards centered on the Primary Point of Interest 134 | (as specified by `self.ppoi`) 135 | """ 136 | imagefile = BytesIO() 137 | palette = image.getpalette() 138 | cropped_image = self.crop_on_centerpoint( 139 | image, 140 | width, 141 | height, 142 | self.ppoi 143 | ) 144 | 145 | # Using ImageOps.fit on GIFs can introduce issues with their palette 146 | # Solution derived from: http://stackoverflow.com/a/4905209/1149774 147 | if image_format == 'GIF': 148 | cropped_image.putpalette(palette) 149 | 150 | cropped_image.save( 151 | imagefile, 152 | **save_kwargs 153 | ) 154 | 155 | return imagefile 156 | 157 | 158 | class ThumbnailImage(SizedImage): 159 | """ 160 | Sizes an image down to fit within a bounding box. 161 | 162 | See the `process_image()` method for more information 163 | """ 164 | 165 | filename_key = 'thumbnail' 166 | 167 | def process_image(self, image, image_format, save_kwargs, 168 | width, height): 169 | """ 170 | Return a BytesIO instance of `image` that fits in a bounding box. 171 | 172 | Bounding box dimensions are `width`x`height`. 173 | """ 174 | imagefile = BytesIO() 175 | image.thumbnail( 176 | (width, height), 177 | ANTIALIAS 178 | ) 179 | image.save( 180 | imagefile, 181 | **save_kwargs 182 | ) 183 | return imagefile 184 | 185 | 186 | class InvertImage(FilteredImage): 187 | """ 188 | Invert the color palette of an image. 189 | 190 | See the `process_image()` for more specifics 191 | """ 192 | 193 | def process_image(self, image, image_format, save_kwargs={}): 194 | """Return a BytesIO instance of `image` with inverted colors.""" 195 | imagefile = BytesIO() 196 | inv_image = ImageOps.invert(image.convert('RGB')) 197 | inv_image.save( 198 | imagefile, 199 | **save_kwargs 200 | ) 201 | return imagefile 202 | 203 | 204 | versatileimagefield_registry.register_sizer('crop', CroppedImage) 205 | versatileimagefield_registry.register_sizer('thumbnail', ThumbnailImage) 206 | versatileimagefield_registry.register_filter('invert', InvertImage) 207 | -------------------------------------------------------------------------------- /versatileimagefield/widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms.widgets import ClearableFileInput, HiddenInput, MultiWidget, Select 2 | from django.utils.safestring import mark_safe 3 | 4 | CENTERPOINT_CHOICES = ( 5 | ('0.0x0.0', 'Top Left'), 6 | ('0.0x0.5', 'Top Center'), 7 | ('0.0x1.0', 'Top Right'), 8 | ('0.5x0.0', 'Middle Left'), 9 | ('0.5x0.5', 'Middle Center'), 10 | ('0.5x1.0', 'Middle Right'), 11 | ('1.0x0.0', 'Bottom Left'), 12 | ('1.0x0.5', 'Bottom Center'), 13 | ('1.0x1.0', 'Bottom Right'), 14 | ) 15 | 16 | 17 | class ClearableFileInputWithImagePreview(ClearableFileInput): 18 | 19 | template_name = 'versatileimagefield/forms/widgets/versatile_image.html' 20 | 21 | def get_hidden_field_id(self, name): 22 | i = name.rindex('_') 23 | return "id_%s_%d" % (name[:i], int(name[i + 1:]) + 1) 24 | 25 | def image_preview_id(self, name): 26 | """Given the name of the image preview tag, return the HTML id for it.""" 27 | return name + '_imagepreview' 28 | 29 | def get_ppoi_id(self, name): 30 | """Given the name of the primary point of interest tag, return the HTML id for it.""" 31 | return name + '_ppoi' 32 | 33 | def get_point_stage_id(self, name): 34 | return name + '_point-stage' 35 | 36 | def get_sized_url(self, value): 37 | """Do not fail completely on invalid images""" 38 | try: 39 | # Ensuring admin preview thumbnails are created and available 40 | value.create_on_demand = True 41 | return value.thumbnail['300x300'] 42 | except Exception: 43 | # Do not be overly specific with exceptions; we'd rather show no 44 | # thumbnail than crash when showing the widget. 45 | return None 46 | 47 | def get_context(self, name, value, attrs): 48 | """Get the context to render this widget with.""" 49 | context = super(ClearableFileInputWithImagePreview, self).get_context(name, value, attrs) 50 | 51 | # It seems Django 1.11's ClearableFileInput doesn't add everything to the 'widget' key, so we can't use it 52 | # in MultiWidget. Add it manually here. 53 | checkbox_name = self.clear_checkbox_name(name) 54 | checkbox_id = self.clear_checkbox_id(checkbox_name) 55 | 56 | context['widget'].update({ 57 | 'checkbox_name': checkbox_name, 58 | 'checkbox_id': checkbox_id, 59 | 'is_initial': self.is_initial(value), 60 | 'input_text': self.input_text, 61 | 'initial_text': self.initial_text, 62 | 'clear_checkbox_label': self.clear_checkbox_label, 63 | }) 64 | 65 | if value and hasattr(value, "url"): 66 | context['widget'].update({ 67 | 'hidden_field_id': self.get_hidden_field_id(name), 68 | 'point_stage_id': self.get_point_stage_id(name), 69 | 'ppoi_id': self.get_ppoi_id(name), 70 | 'sized_url': self.get_sized_url(value), 71 | 'image_preview_id': self.image_preview_id(name), 72 | }) 73 | 74 | return context 75 | 76 | def build_attrs(self, base_attrs, extra_attrs=None): 77 | """Build an attribute dictionary.""" 78 | attrs = base_attrs.copy() 79 | if extra_attrs is not None: 80 | attrs.update(extra_attrs) 81 | return attrs 82 | 83 | 84 | class SizedImageCenterpointWidgetMixIn(object): 85 | 86 | def decompress(self, value): 87 | return [value, 'x'.join(str(num) for num in value.ppoi)] if value else [None, None] 88 | 89 | 90 | class VersatileImagePPOISelectWidget(SizedImageCenterpointWidgetMixIn, MultiWidget): 91 | 92 | def __init__(self, widgets=None, attrs=None): 93 | widgets = [ 94 | ClearableFileInput(attrs=None), 95 | Select(attrs=attrs, choices=CENTERPOINT_CHOICES) 96 | ] 97 | super(VersatileImagePPOISelectWidget, self).__init__(widgets, attrs) 98 | 99 | 100 | class VersatileImagePPOIClickWidget(SizedImageCenterpointWidgetMixIn, MultiWidget): 101 | 102 | def __init__(self, widgets=None, attrs=None, image_preview_template=None): 103 | widgets = ( 104 | ClearableFileInputWithImagePreview(attrs={'class': 'file-chooser'}), 105 | HiddenInput(attrs={'class': 'ppoi-input'}) 106 | ) 107 | super(VersatileImagePPOIClickWidget, self).__init__(widgets, attrs) 108 | 109 | class Media: 110 | css = { 111 | 'all': ('versatileimagefield/css/versatileimagefield.css',), 112 | } 113 | js = ('versatileimagefield/js/versatileimagefield.js',) 114 | 115 | def render(self, name, value, attrs=None, renderer=None): 116 | rendered = super(VersatileImagePPOIClickWidget, self).render(name, value, attrs=attrs) 117 | return mark_safe('
{}
'.format(rendered)) 118 | 119 | 120 | class SizedImageCenterpointClickDjangoAdminWidget(VersatileImagePPOIClickWidget): 121 | 122 | class Media: 123 | css = { 124 | 'all': ('versatileimagefield/css/versatileimagefield-djangoadmin.css',), 125 | } 126 | 127 | 128 | class Bootstrap3ClearableFileInputWithImagePreview(ClearableFileInputWithImagePreview): 129 | """A Bootstrap 3 version of the clearable file input with image preview.""" 130 | 131 | template_name = 'versatileimagefield/forms/widgets/versatile_image_bootstrap.html' 132 | 133 | 134 | class SizedImageCenterpointClickBootstrap3Widget(VersatileImagePPOIClickWidget): 135 | 136 | def __init__(self, widgets=None, attrs=None): 137 | widgets = ( 138 | Bootstrap3ClearableFileInputWithImagePreview(attrs={'class': 'file-chooser'}), 139 | HiddenInput(attrs={'class': 'ppoi-input'}) 140 | ) 141 | super(VersatileImagePPOIClickWidget, self).__init__(widgets, attrs) 142 | 143 | class Media: 144 | css = { 145 | 'all': ('versatileimagefield/css/versatileimagefield-bootstrap3.css',), 146 | } 147 | --------------------------------------------------------------------------------