├── .editorconfig ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── Vagrantfile ├── codecov.yml ├── docs ├── Makefile ├── _theme │ └── nature │ │ ├── static │ │ ├── nature.css_t │ │ └── pygments.css │ │ └── theme.conf ├── conf.py ├── contributing.rst ├── examples.rst ├── index.rst ├── installation.rst ├── logging.rst ├── make.bat ├── management.rst ├── operation.rst ├── reference │ ├── image.rst │ ├── index.rst │ └── settings.rst ├── requirements.rst └── template.rst ├── pyproject.toml ├── sorl ├── __init__.py └── thumbnail │ ├── __init__.py │ ├── admin │ ├── __init__.py │ └── current.py │ ├── base.py │ ├── conf │ ├── __init__.py │ └── defaults.py │ ├── default.py │ ├── engines │ ├── __init__.py │ ├── base.py │ ├── convert_engine.py │ ├── pgmagick_engine.py │ ├── pil_engine.py │ ├── vipsthumbnail_engine.py │ └── wand_engine.py │ ├── fields.py │ ├── helpers.py │ ├── images.py │ ├── kvstores │ ├── __init__.py │ ├── base.py │ ├── cached_db_kvstore.py │ ├── dbm_kvstore.py │ ├── dynamodb_kvstore.py │ └── redis_kvstore.py │ ├── log.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── thumbnail.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── parsers.py │ ├── shortcuts.py │ └── templatetags │ ├── __init__.py │ ├── sorl_thumbnail.py │ └── thumbnail.py ├── tests ├── __init__.py ├── data │ ├── 1_topleft.jpg │ ├── 2_topright.jpg │ ├── 3_bottomright.jpg │ ├── 4_bottomleft.jpg │ ├── 5_lefttop.jpg │ ├── 6_righttop.jpg │ ├── 7_rightbottom.jpg │ ├── 8_leftbottom.jpg │ ├── animation_w_transparency.gif │ ├── aspect_test.jpg │ ├── broken.jpeg │ ├── icc_profile_test.jpg │ └── white_border.jpg ├── settings │ ├── __init__.py │ ├── dbm.py │ ├── default.py │ ├── dynamodb.py │ ├── graphicsmagick.py │ ├── imagemagick.py │ ├── pgmagick.py │ ├── pil.py │ ├── redis.py │ ├── vipsthumbnail.py │ └── wand.py └── thumbnail_tests │ ├── __init__.py │ ├── kvstore.py │ ├── models.py │ ├── storage.py │ ├── templates │ ├── htmlfilter.html │ ├── markdownfilter.html │ ├── thumbnail1.html │ ├── thumbnail1_alias.html │ ├── thumbnail2.html │ ├── thumbnail20.html │ ├── thumbnail20a.html │ ├── thumbnail2_alias.html │ ├── thumbnail3.html │ ├── thumbnail4.html │ ├── thumbnail4a.html │ ├── thumbnail5.html │ ├── thumbnail6.html │ ├── thumbnail6_alias.html │ ├── thumbnail7.html │ ├── thumbnail7_alias.html │ ├── thumbnail7a.html │ ├── thumbnail7a_alias.html │ ├── thumbnail8.html │ ├── thumbnail8_alias.html │ ├── thumbnail8a.html │ ├── thumbnail8a_alias.html │ ├── thumbnail9.html │ ├── thumbnaild1.html │ ├── thumbnaild2.html │ ├── thumbnaild3.html │ └── thumbnaild4.html │ ├── test_admin.py │ ├── test_alternative_resolutions.py │ ├── test_backends.py │ ├── test_commands.py │ ├── test_engines.py │ ├── test_filters.py │ ├── test_kvstore.py │ ├── test_parsers.py │ ├── test_storage.py │ ├── test_templatetags.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── tox.ini └── vagrant.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at https://EditorConfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | charset = utf-8 10 | 11 | [*.py] 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.rst] 18 | trim_trailing_whitespace = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | 23 | [*.yml] 24 | indent_style = space 25 | indent_size = 2 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | release: 8 | types: 9 | - published 10 | 11 | jobs: 12 | build: 13 | if: github.repository == 'jazzband/sorl-thumbnail' 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.9 25 | 26 | - name: Get pip cache dir 27 | id: pip-cache 28 | run: | 29 | echo "CACHE_DIR=$(pip cache dir)" >> "$GITHUB_OUTPUT" 30 | 31 | - name: Cache 32 | uses: actions/cache@v4 33 | with: 34 | path: ${{ steps.pip-cache.outputs.CACHE_DIR }} 35 | key: release-${{ hashFiles('**/pyproject.toml') }} 36 | restore-keys: | 37 | release- 38 | 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install -U pip build twine 42 | 43 | - name: Build package 44 | run: | 45 | python -m build 46 | twine check dist/* 47 | 48 | - name: Upload packages to Jazzband 49 | if: github.event.action == 'published' 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | with: 52 | user: jazzband 53 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 54 | repository_url: https://jazzband.co/projects/sorl-thumbnail/upload 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ['3.9', '3.13'] 11 | target: [pil, imagemagick, graphicsmagick, redis, wand, dbm] 12 | 13 | include: 14 | - python-version: '3.9' 15 | target: 'qa' 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Start Redis 20 | uses: supercharge/redis-github-action@1.5.0 21 | 22 | - name: Install system dependencies 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install libgraphicsmagick1-dev graphicsmagick libjpeg62 zlib1g-dev 26 | 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Get pip cache dir 33 | id: pip-cache 34 | run: | 35 | echo "CACHE_DIR=$(pip cache dir)" >> "$GITHUB_OUTPUT" 36 | 37 | - name: Cache 38 | uses: actions/cache@v4 39 | with: 40 | path: ${{ steps.pip-cache.outputs.CACHE_DIR }} 41 | key: 42 | test-${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }} 43 | restore-keys: | 44 | test-${{ matrix.python-version }}-v1- 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | python -m pip install --upgrade tox tox-gh-actions 50 | 51 | - name: Tox tests 52 | run: | 53 | tox -v 54 | env: 55 | TARGET: ${{ matrix.target }} 56 | 57 | - name: Upload coverage 58 | uses: codecov/codecov-action@v4 59 | with: 60 | name: Python ${{ matrix.python-version }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | env/ 7 | bin/ 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | *.egg-info/ 18 | *.egg 19 | .eggs 20 | .installed.cfg 21 | 22 | # Installer logs 23 | pip-log.txt 24 | pip-delete-this-directory.txt 25 | 26 | # Translations 27 | *.mo 28 | 29 | # Related to Desktops and OS 30 | .DS_Store 31 | .AppleDouble 32 | .LSOverride 33 | ._* 34 | .~ 35 | .bak 36 | *.swp 37 | *.swo 38 | *.swn 39 | 40 | # VCS and DVCS 41 | .svn 42 | 43 | # Common IDE's 44 | .idea 45 | .project 46 | .pydevproject 47 | .ropeproject 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | 52 | # Virtualenv 53 | .python-version 54 | .env/ 55 | 56 | # dbm stuff 57 | tests/thumbnail_kvstore 58 | tests/thumbnail_kvstore.db 59 | tests/thumbnail_kvstore.lock 60 | 61 | # test related 62 | .coverage 63 | .tox 64 | htmlcov/ 65 | coverage.xml 66 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.5 4 | hooks: 5 | - id: ruff 6 | args: [ --fix ] 7 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Take a look at https://github.com/jazzband/sorl-thumbnail/graphs/contributors 2 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | Unreleased 6 | ========== 7 | * ``THUMBNAIL_STORAGE`` should now be an alias in the Django ``STORAGES`` setting. 8 | The old way of specifying a dotted path to a Storage module is still supported. 9 | * Confirmed support for Python 3.13 (on Django 5.1+). 10 | * Drop support for Python 3.8. 11 | 12 | 12.11.0 13 | ======= 14 | * Deprecated ``THUMBNAIL_KVSTORE``. Only the Django cache-based store will be 15 | used in a future version. 16 | * Add support for Django 5.0 and 5.1 17 | * Add support for installing with optional dependencies. 18 | * Drop support for Django 3.2, 4.0 and 4.1 19 | 20 | 12.10.0 21 | ======= 22 | * Fixed safe_filter application on various thumbnail template tags. 23 | * Fixed slow performance with external storages like S3. 24 | * Added support for Django 4.2. 25 | * Drop support for Python 3.7. 26 | * Confirmed Python 3.11 support. 27 | 28 | 12.9.0 29 | ====== 30 | * Drop support for Django 2.2 and 3.1. 31 | * Added support for Django 4.1. 32 | * Drop support for Python 3.6. 33 | * Fixed various Pillow deprecation warnings. 34 | 35 | 12.8.0 36 | ====== 37 | * Drop support for Django 3.0. 38 | * Added support for Django 3.2 and 4.0. 39 | * Confirmed Python 3.9 and 3.10 support. 40 | * Adapted size regex getting size from the identify output. #532 41 | * Display possible ``thumbnail`` command labels in command help. 42 | * Added Jazzband code of conduct. 43 | 44 | 45 | 12.7.0 46 | ====== 47 | * Drop support for Django 1.11 48 | * Added support for Django 3.1 49 | * Moved to GitHub Action for continuous integration. 50 | * Correction in convert_engine with unknown exif orientation 51 | * Using more resilient _get_exif_orientation logic in convert engine 52 | * Update wand_engine.py for ImageMagick 7 53 | * Fix cannot write mode RGBA as JPEG when thumbnailing a GIF 54 | 55 | 56 | 12.6.3 57 | ====== 58 | 59 | * Deprecate Python 2 compatibility shims in favor of Python 3 only codebase. #623 60 | * Fix README on notes about ImageField cleaning up references on delete. #624 61 | * Fix image ratios with EXIF orientation. #619 62 | * Fix test coverage tracking. #622 and #617 63 | 64 | 65 | 12.6.2 66 | ====== 67 | 68 | * Fix rST syntax errors from 12.6.0 and 12.6.1 that blocked release. #613 69 | * Improve QA setup and add rST validation to Travis and tox test matrix. #613 70 | 71 | 72 | 12.6.1 73 | ====== 74 | 75 | * Deprecate explicit support for Python 3.4 and 3.5 in order to simplify the test matrix #610 76 | * Add requirement for ``setuptools_scm`` to automatically resolve version from git tags #610 77 | * Removed property ``thumbnail.__version__`` #610 78 | 79 | 80 | 12.6.0 81 | ====== 82 | 83 | * Add Cropbox feature in Wand/Convert Engine 84 | * Add testing for Django 2.2 85 | * Remove "django.utils.six" to support Django 3.0+ 86 | * Remove Python 2 support 87 | 88 | 89 | 12.5.0 90 | ====== 91 | 92 | * Make the template tag accept a falsey image 93 | * Update identify (of convert_engine) for faster multi-page PDF thumbnailing 94 | * Fix Redis KVStore timeout 95 | * Fix format conversion in Wand engine 96 | * Added setting THUMBNAIL_REMOVE_URL_ARGS 97 | * Add testing for Django 2.1 98 | * Drop support for Django < 1.11 99 | * Added ssl parameter to Redis object instantiation 100 | * Fix 2 ResourceWarning: unclosed file, in tests 101 | * Fix AdminImageWidget with Django 2.1 102 | * Test in release version of Python 3.7 103 | * Remove unused unittest imports in thumbnail_tests.compat 104 | * Add a __str__ method to ImageFile 105 | 106 | 107 | 12.4.1 108 | ====== 109 | 110 | sorl-thumbnail was welcomed into the `Jazzband organization and project 111 | `__. Jazzband is open to all, and any member of Jazzband 112 | can contribute directly to sorl-thumbnail's GitHub repo. We hope this will 113 | encourage more open source programmers to contribute. Thank you @mariocesar for 114 | taking this step and for the years of effort in this project. 115 | 116 | 12.4.1 is the first release on PyPI since the migration to the Jazzband 117 | project, and includes two years' worth of changes. Thank you to all 118 | contributors. These are some of the highlights: 119 | 120 | * Target Django versions are now 1.8, 1.10, 1.11 and 2.0 121 | * Target Python versions are now 2.7, 3.3, 3.4, 3.5 and 3.6 122 | * Enable GIF support (#263) 123 | * Enable WebP support (#460) 124 | * New ``sorl_thumbnail`` templatetag library that mirrors traditional ``thumbnail`` 125 | * Fix bug RGBA mode not compatible with JPEG on PILLOW >=3.7 (#503) 126 | * Don't check EXIF orientation with GraphicsMagick 127 | * Bug fix for handling non-ASCII characters in filenames (#434) 128 | * Better error detection and handling in some cases (#492) 129 | * Improve automated testing 130 | * Improve documentation 131 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | |Jazzband| 2 | 3 | This is a `Jazzband `__ project. By contributing 4 | you agree to abide by the `Contributor Code of 5 | Conduct `__ and follow the 6 | `guidelines `__. 7 | 8 | .. |Jazzband| image:: https://jazzband.co/static/img/jazzband.svg 9 | :target: https://jazzband.co/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Mikko Hellsing 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the sorl-thumbnail the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst CHANGES.rst AUTHORS.rst 2 | recursive-include docs * 3 | recursive-include tests * 4 | recursive-exclude * *.pyc 5 | prune docs/_build 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |jazzband-badge| |pypi| |python-badge| |django-badge| |docs| |gh-actions| |codecov| 2 | 3 | Thumbnails for Django. 4 | 5 | Features at a glance 6 | ==================== 7 | 8 | - Support for Django 4.2, 5.0 and 5.1 following the `Django supported versions policy`_ 9 | - Storage support 10 | - Pluggable Engine support for `Pillow`_, `ImageMagick`_, `PIL`_, `Wand`_, `pgmagick`_, and `vipsthumbnail`_ 11 | - Pluggable Key Value Store support (cached db, redis, and dynamodb by AWS) 12 | - Pluggable Backend support 13 | - Admin integration with possibility to delete 14 | - Dummy generation (placeholders) 15 | - Flexible, simple syntax, generates no html 16 | - ImageField for model that deletes thumbnails (only compatible with django 1.2.5 or less) 17 | - CSS style cropping options 18 | - Back smart cropping, and remove borders from the images when cropping 19 | - Margin calculation for vertical positioning 20 | - Alternative resolutions versions of a thumbnail 21 | 22 | Read more in `the documentation (latest version) `_ 23 | 24 | Developers 25 | ========== 26 | 27 | |jazzband| 28 | 29 | This is a `Jazzband `_ project. By contributing you agree to 30 | abide by the `Contributor Code of Conduct `_ 31 | and follow the `guidelines `_. 32 | 33 | Feel free to create a new Pull request if you want to propose a new feature. 34 | If you need development support or want to discuss with other developers 35 | join us in the channel #sorl-thumbnail at freenode.net or Gitter. 36 | 37 | For releases updates and more in deep development discussion use our mailing list 38 | in Google Groups. 39 | 40 | - IRC Channel: irc://irc.freenode.net/#sorl-thumbnail 41 | 42 | - Mailing List: sorl-thumbnail@googlegroups.com https://groups.google.com/d/forum/sorl-thumbnail 43 | 44 | Tests 45 | ----- 46 | The tests should run with tox. Running `tox` will run all tests for all environments. 47 | However, it is possible to run a certain environment with `tox -e `, a list of all environments 48 | can be found with `tox -l`. These tests require the dependencies of the different engines defined in 49 | the documentation. It is possible to install these dependencies into a vagrant image with the 50 | Vagrantfile in the repo. 51 | 52 | User Support 53 | ============ 54 | 55 | If you need help using sorl-thumbnail browse https://stackoverflow.com/questions/tagged/sorl-thumbnail 56 | and posts your questions with the `sorl-thumbnail` tag. 57 | 58 | 59 | How to Use 60 | ========== 61 | 62 | Get the code 63 | ------------ 64 | 65 | Getting the code for the latest stable release use 'pip'. :: 66 | 67 | $ pip install sorl-thumbnail 68 | 69 | Install in your project 70 | ----------------------- 71 | 72 | Then register 'sorl.thumbnail', in the 'INSTALLED_APPS' section of 73 | your project's settings. :: 74 | 75 | INSTALLED_APPS = [ 76 | 'django.contrib.admin', 77 | 'django.contrib.auth', 78 | 'django.contrib.contenttypes', 79 | 'django.contrib.sessions', 80 | 'django.contrib.messages', 81 | 'django.contrib.staticfiles', 82 | 83 | 'sorl.thumbnail', 84 | ] 85 | 86 | 87 | Templates Usage 88 | --------------- 89 | 90 | All of the examples assume that you first load the thumbnail template tag in 91 | your template.:: 92 | 93 | {% load thumbnail %} 94 | 95 | 96 | A simple usage. :: 97 | 98 | {% thumbnail item.image "100x100" crop="center" as im %} 99 | 100 | {% endthumbnail %} 101 | 102 | See more examples in the section `Template examples`_ in the Documentation 103 | 104 | Model Usage 105 | ----------- 106 | 107 | Using the ImageField that automatically deletes references to itself in the key 108 | value store and its thumbnail references and the thumbnail files when deleted. 109 | Please note that this is only compatible with django 1.2.5 or less.:: 110 | 111 | from django.db import models 112 | from sorl.thumbnail import ImageField 113 | 114 | class Item(models.Model): 115 | image = ImageField(upload_to='whatever') 116 | 117 | See more examples in the section `Model examples`_ in the Documentation 118 | 119 | Low level API 120 | ------------- 121 | 122 | You can use the 'get_thumbnail':: 123 | 124 | from sorl.thumbnail import get_thumbnail 125 | from sorl.thumbnail import delete 126 | 127 | im = get_thumbnail(my_file, '100x100', crop='center', quality=99) 128 | delete(my_file) 129 | 130 | See more examples in the section `Low level API examples`_ in the Documentation 131 | 132 | Using in combination with other thumbnailers 133 | -------------------------------------------- 134 | 135 | Alternatively, you load the templatetags by {% load sorl_thumbnail %} 136 | instead of traditional {% load thumbnail %}. It's especially useful in 137 | projects that do make use of multiple thumbnailer libraries that use the 138 | same name (``thumbnail``) for the templatetag module:: 139 | 140 | {% load sorl_thumbnail %} 141 | {% thumbnail item.image "100x100" crop="center" as im %} 142 | 143 | {% endthumbnail %} 144 | 145 | Frequently asked questions 146 | ========================== 147 | 148 | Is so slow in Amazon S3! 149 | ------------------------ 150 | 151 | Possible related to the implementation of your Amazon S3 Backend, see the `issue #351`_ 152 | due the storage backend reviews if there is an existing thumbnail when tries to 153 | generate the thumbnail that makes an extensive use of the S3 API 154 | 155 | A fast workaround if you are not willing to tweak your storage backend is to set:: 156 | 157 | THUMBNAIL_FORCE_OVERWRITE = True 158 | 159 | So it will avoid to overly query the S3 API. 160 | 161 | 162 | .. |gh-actions| image:: https://github.com/jazzband/sorl-thumbnail/workflows/Test/badge.svg 163 | :target: https://github.com/jazzband/sorl-thumbnail/actions 164 | .. |docs| image:: https://readthedocs.org/projects/pip/badge/?version=latest 165 | :alt: Documentation for latest version 166 | :target: https://sorl-thumbnail.readthedocs.io/en/latest/ 167 | .. |pypi| image:: https://img.shields.io/pypi/v/sorl-thumbnail.svg 168 | :target: https://pypi.python.org/pypi/sorl-thumbnail 169 | :alt: sorl-thumbnail on PyPI 170 | .. |python-badge| image:: https://img.shields.io/pypi/pyversions/sorl-thumbnail.svg 171 | :target: https://pypi.python.org/pypi/sorl-thumbnail 172 | :alt: Supported Python versions 173 | .. |django-badge| image:: https://img.shields.io/pypi/djversions/sorl-thumbnail.svg 174 | :target: https://pypi.python.org/pypi/sorl-thumbnail 175 | :alt: Supported Python versions 176 | .. |codecov| image:: https://codecov.io/gh/jazzband/sorl-thumbnail/branch/master/graph/badge.svg 177 | :target: https://codecov.io/gh/jazzband/sorl-thumbnail 178 | :alt: Coverage 179 | .. |jazzband-badge| image:: https://jazzband.co/static/img/badge.svg 180 | :target: https://jazzband.co/ 181 | :alt: Jazzband 182 | .. |jazzband| image:: https://jazzband.co/static/img/jazzband.svg 183 | :target: https://jazzband.co/ 184 | :alt: Jazzband 185 | 186 | .. _`Pillow`: https://pillow.readthedocs.io/ 187 | .. _`ImageMagick`: https://imagemagick.org/ 188 | .. _`PIL`: https://python-pillow.org/ 189 | .. _`Wand`: https://docs.wand-py.org/ 190 | .. _`pgmagick`: https://pgmagick.readthedocs.io/ 191 | .. _`vipsthumbnail`: https://www.libvips.org/API/current/Using-vipsthumbnail.html 192 | 193 | .. _`Template examples`: https://sorl-thumbnail.readthedocs.io/en/latest/examples.html#template-examples 194 | .. _`Model examples`: https://sorl-thumbnail.readthedocs.io/en/latest/examples.html#model-examples 195 | .. _`Low level API examples`: https://sorl-thumbnail.readthedocs.io/en/latest/examples.html#low-level-api-examples 196 | .. _`issue #351`: https://github.com/jazzband/sorl-thumbnail/issues/351 197 | .. _`Django supported versions policy`: https://www.djangoproject.com/download/#supported-versions 198 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | VAGRANTFILE_API_VERSION = "2" 5 | 6 | Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| 7 | config.vm.box = "precise64" 8 | config.vm.box_url = "http://files.vagrantup.com/precise64.box" 9 | config.vm.provision :shell, :path => "vagrant.sh" 10 | end 11 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: "60...100" 5 | -------------------------------------------------------------------------------- /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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/sorlthumbnail.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/sorlthumbnail.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/sorlthumbnail" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/sorlthumbnail" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/_theme/nature/static/nature.css_t: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphinx stylesheet -- default theme 3 | * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | */ 5 | 6 | @import url("basic.css"); 7 | 8 | /* -- page layout ----------------------------------------------------------- */ 9 | 10 | body { 11 | font-family: Arial, sans-serif; 12 | font-size: 100%; 13 | background-color: #111; 14 | color: #555; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | div.documentwrapper { 20 | float: left; 21 | width: 100%; 22 | } 23 | 24 | div.bodywrapper { 25 | margin: 0 0 0 230px; 26 | } 27 | 28 | hr{ 29 | border: 1px solid #B1B4B6; 30 | } 31 | 32 | div.document { 33 | background-color: #eee; 34 | } 35 | 36 | div.body { 37 | background-color: #ffffff; 38 | color: #3E4349; 39 | padding: 0 30px 30px 30px; 40 | font-size: 0.8em; 41 | } 42 | 43 | div.footer { 44 | color: #555; 45 | width: 100%; 46 | padding: 13px 0; 47 | text-align: center; 48 | font-size: 75%; 49 | } 50 | 51 | div.footer a { 52 | color: #444; 53 | text-decoration: underline; 54 | } 55 | 56 | div.related { 57 | background-color: #6BA81E; 58 | line-height: 32px; 59 | color: #fff; 60 | text-shadow: 0px 1px 0 #444; 61 | font-size: 0.80em; 62 | } 63 | 64 | div.related a { 65 | color: #E2F3CC; 66 | } 67 | 68 | div.sphinxsidebar { 69 | font-size: 0.75em; 70 | line-height: 1.5em; 71 | } 72 | 73 | div.sphinxsidebarwrapper{ 74 | padding: 20px 0; 75 | } 76 | 77 | div.sphinxsidebar h3, 78 | div.sphinxsidebar h4 { 79 | font-family: Arial, sans-serif; 80 | color: #222; 81 | font-size: 1.2em; 82 | font-weight: normal; 83 | margin: 0; 84 | padding: 5px 10px; 85 | background-color: #ddd; 86 | text-shadow: 1px 1px 0 white 87 | } 88 | 89 | div.sphinxsidebar h4{ 90 | font-size: 1.1em; 91 | } 92 | 93 | div.sphinxsidebar h3 a { 94 | color: #444; 95 | } 96 | 97 | 98 | div.sphinxsidebar p { 99 | color: #888; 100 | padding: 5px 20px; 101 | } 102 | 103 | div.sphinxsidebar p.topless { 104 | } 105 | 106 | div.sphinxsidebar ul { 107 | margin: 10px 20px; 108 | padding: 0; 109 | color: #000; 110 | } 111 | 112 | div.sphinxsidebar a { 113 | color: #444; 114 | } 115 | 116 | div.sphinxsidebar input { 117 | border: 1px solid #ccc; 118 | font-family: sans-serif; 119 | font-size: 1em; 120 | } 121 | 122 | div.sphinxsidebar input[type=text]{ 123 | margin-left: 20px; 124 | } 125 | 126 | /* -- body styles ----------------------------------------------------------- */ 127 | 128 | a { 129 | color: #005B81; 130 | text-decoration: none; 131 | } 132 | 133 | a:hover { 134 | color: #E32E00; 135 | text-decoration: underline; 136 | } 137 | 138 | div.body h1, 139 | div.body h2, 140 | div.body h3, 141 | div.body h4, 142 | div.body h5, 143 | div.body h6 { 144 | font-family: Arial, sans-serif; 145 | background-color: #BED4EB; 146 | font-weight: normal; 147 | color: #212224; 148 | margin: 30px 0px 10px 0px; 149 | padding: 5px 0 5px 10px; 150 | text-shadow: 0px 1px 0 white 151 | } 152 | 153 | div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } 154 | div.body h2 { font-size: 150%; background-color: #C8D5E3; } 155 | div.body h3 { font-size: 120%; background-color: #D8DEE3; } 156 | div.body h4 { font-size: 110%; background-color: #D8DEE3; } 157 | div.body h5 { font-size: 100%; background-color: #D8DEE3; } 158 | div.body h6 { font-size: 100%; background-color: #D8DEE3; } 159 | 160 | a.headerlink { 161 | color: #c60f0f; 162 | font-size: 0.8em; 163 | padding: 0 4px 0 4px; 164 | text-decoration: none; 165 | } 166 | 167 | a.headerlink:hover { 168 | background-color: #c60f0f; 169 | color: white; 170 | } 171 | 172 | div.body p, div.body dd, div.body li { 173 | line-height: 1.5em; 174 | } 175 | 176 | div.admonition p.admonition-title + p { 177 | display: inline; 178 | } 179 | 180 | div.highlight{ 181 | background-color: white; 182 | } 183 | 184 | div.note { 185 | background-color: #eee; 186 | border: 1px solid #ccc; 187 | } 188 | 189 | div.seealso { 190 | background-color: #ffc; 191 | border: 1px solid #ff6; 192 | } 193 | 194 | div.topic { 195 | background-color: #eee; 196 | } 197 | 198 | div.warning { 199 | background-color: #ffe4e4; 200 | border: 1px solid #f66; 201 | } 202 | 203 | p.admonition-title { 204 | display: inline; 205 | } 206 | 207 | p.admonition-title:after { 208 | content: ":"; 209 | } 210 | 211 | pre { 212 | padding: 10px; 213 | background-color: White; 214 | color: #222; 215 | line-height: 1.2em; 216 | border: 1px solid #C6C9CB; 217 | font-size: 1.2em; 218 | margin: 1.5em 0 1.5em 0; 219 | -webkit-box-shadow: 1px 1px 1px #d8d8d8; 220 | -moz-box-shadow: 1px 1px 1px #d8d8d8; 221 | } 222 | 223 | tt { 224 | background-color: #ecf0f3; 225 | color: #222; 226 | padding: 1px 2px; 227 | font-size: 1.2em; 228 | font-family: monospace; 229 | } 230 | -------------------------------------------------------------------------------- /docs/_theme/nature/static/pygments.css: -------------------------------------------------------------------------------- 1 | .c { color: #999988; font-style: italic } /* Comment */ 2 | .k { font-weight: bold } /* Keyword */ 3 | .o { font-weight: bold } /* Operator */ 4 | .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 5 | .cp { color: #999999; font-weight: bold } /* Comment.preproc */ 6 | .c1 { color: #999988; font-style: italic } /* Comment.Single */ 7 | .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 8 | .ge { font-style: italic } /* Generic.Emph */ 9 | .gr { color: #aa0000 } /* Generic.Error */ 10 | .gh { color: #999999 } /* Generic.Heading */ 11 | .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 12 | .go { color: #111 } /* Generic.Output */ 13 | .gp { color: #555555 } /* Generic.Prompt */ 14 | .gs { font-weight: bold } /* Generic.Strong */ 15 | .gu { color: #aaaaaa } /* Generic.Subheading */ 16 | .gt { color: #aa0000 } /* Generic.Traceback */ 17 | .kc { font-weight: bold } /* Keyword.Constant */ 18 | .kd { font-weight: bold } /* Keyword.Declaration */ 19 | .kp { font-weight: bold } /* Keyword.Pseudo */ 20 | .kr { font-weight: bold } /* Keyword.Reserved */ 21 | .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 22 | .m { color: #009999 } /* Literal.Number */ 23 | .s { color: #bb8844 } /* Literal.String */ 24 | .na { color: #008080 } /* Name.Attribute */ 25 | .nb { color: #999999 } /* Name.Builtin */ 26 | .nc { color: #445588; font-weight: bold } /* Name.Class */ 27 | .no { color: #ff99ff } /* Name.Constant */ 28 | .ni { color: #800080 } /* Name.Entity */ 29 | .ne { color: #990000; font-weight: bold } /* Name.Exception */ 30 | .nf { color: #990000; font-weight: bold } /* Name.Function */ 31 | .nn { color: #555555 } /* Name.Namespace */ 32 | .nt { color: #000080 } /* Name.Tag */ 33 | .nv { color: purple } /* Name.Variable */ 34 | .ow { font-weight: bold } /* Operator.Word */ 35 | .mf { color: #009999 } /* Literal.Number.Float */ 36 | .mh { color: #009999 } /* Literal.Number.Hex */ 37 | .mi { color: #009999 } /* Literal.Number.Integer */ 38 | .mo { color: #009999 } /* Literal.Number.Oct */ 39 | .sb { color: #bb8844 } /* Literal.String.Backtick */ 40 | .sc { color: #bb8844 } /* Literal.String.Char */ 41 | .sd { color: #bb8844 } /* Literal.String.Doc */ 42 | .s2 { color: #bb8844 } /* Literal.String.Double */ 43 | .se { color: #bb8844 } /* Literal.String.Escape */ 44 | .sh { color: #bb8844 } /* Literal.String.Heredoc */ 45 | .si { color: #bb8844 } /* Literal.String.Interpol */ 46 | .sx { color: #bb8844 } /* Literal.String.Other */ 47 | .sr { color: #808000 } /* Literal.String.Regex */ 48 | .s1 { color: #bb8844 } /* Literal.String.Single */ 49 | .ss { color: #bb8844 } /* Literal.String.Symbol */ 50 | .bp { color: #999999 } /* Name.Builtin.Pseudo */ 51 | .vc { color: #ff99ff } /* Name.Variable.Class */ 52 | .vg { color: #ff99ff } /* Name.Variable.Global */ 53 | .vi { color: #ff99ff } /* Name.Variable.Instance */ 54 | .il { color: #009999 } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /docs/_theme/nature/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = nature.css 4 | pygments_style = tango 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # sorl-thumbnail documentation build configuration file, created by 2 | # sphinx-quickstart on Fri Nov 12 00:51:21 2010. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import pkg_resources 13 | 14 | # -- General configuration ----------------------------------------------------- 15 | 16 | # If your documentation needs a minimal Sphinx version, state it here. 17 | #needs_sphinx = '1.0' 18 | 19 | # Add any Sphinx extension module names here, as strings. They can be extensions 20 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 21 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 22 | 23 | # Add any paths that contain templates here, relative to this directory. 24 | templates_path = ['_templates'] 25 | 26 | # The suffix of source filenames. 27 | source_suffix = '.rst' 28 | 29 | # The encoding of source files. 30 | #source_encoding = 'utf-8-sig' 31 | 32 | # The master toctree document. 33 | master_doc = 'index' 34 | 35 | # General information about the project. 36 | project = 'sorl-thumbnail' 37 | copyright = '2010, Mikko Hellsing' 38 | 39 | # The version info for the project you're documenting, acts as replacement for 40 | # |version| and |release|, also used in various other places throughout the 41 | # built documents. 42 | # 43 | # The short X.Y version. 44 | version = pkg_resources.get_distribution("sorl-thumbnail").version 45 | # The full version, including alpha/beta/rc tags. 46 | release = version 47 | 48 | # The language for content autogenerated by Sphinx. Refer to documentation 49 | # for a list of supported languages. 50 | #language = None 51 | 52 | # There are two options for replacing |today|: either, you set today to some 53 | # non-false value, then it is used: 54 | #today = '' 55 | # Else, today_fmt is used as the format for a strftime call. 56 | #today_fmt = '%B %d, %Y' 57 | 58 | # List of patterns, relative to source directory, that match files and 59 | # directories to ignore when looking for source files. 60 | exclude_patterns = ['_build'] 61 | 62 | # The reST default role (used for this markup: `text`) to use for all documents. 63 | #default_role = None 64 | 65 | # If true, '()' will be appended to :func: etc. cross-reference text. 66 | #add_function_parentheses = True 67 | 68 | # If true, the current module name will be prepended to all description 69 | # unit titles (such as .. function::). 70 | #add_module_names = True 71 | 72 | # If true, sectionauthor and moduleauthor directives will be shown in the 73 | # output. They are ignored by default. 74 | #show_authors = False 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # A list of ignored prefixes for module index sorting. 80 | #modindex_common_prefix = [] 81 | 82 | 83 | # -- Options for HTML output --------------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | #html_theme = 'default' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | #html_theme_options = {} 93 | 94 | # Add any paths that contain custom themes here, relative to this directory. 95 | #html_theme_path = ['_theme'] 96 | 97 | # The name for this set of Sphinx documents. If None, it defaults to 98 | # " v documentation". 99 | #html_title = None 100 | 101 | # A shorter title for the navigation bar. Default is the same as html_title. 102 | #html_short_title = None 103 | 104 | # The name of an image file (relative to this directory) to place at the top 105 | # of the sidebar. 106 | #html_logo = None 107 | 108 | # The name of an image file (within the static path) to use as favicon of the 109 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 110 | # pixels large. 111 | #html_favicon = None 112 | 113 | # Add any paths that contain custom static files (such as style sheets) here, 114 | # relative to this directory. They are copied after the builtin static files, 115 | # so a file named "default.css" will overwrite the builtin "default.css". 116 | # html_static_path = ['_static'] 117 | 118 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 119 | # using the given strftime format. 120 | #html_last_updated_fmt = '%b %d, %Y' 121 | 122 | # If true, SmartyPants will be used to convert quotes and dashes to 123 | # typographically correct entities. 124 | #html_use_smartypants = True 125 | 126 | # Custom sidebar templates, maps document names to template names. 127 | #html_sidebars = {} 128 | 129 | # Additional templates that should be rendered to pages, maps page names to 130 | # template names. 131 | #html_additional_pages = {} 132 | 133 | # If false, no module index is generated. 134 | #html_domain_indices = True 135 | 136 | # If false, no index is generated. 137 | #html_use_index = True 138 | 139 | # If true, the index is split into individual pages for each letter. 140 | #html_split_index = False 141 | 142 | # If true, links to the reST sources are added to the pages. 143 | #html_show_sourcelink = True 144 | 145 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 146 | #html_show_sphinx = True 147 | 148 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 149 | #html_show_copyright = True 150 | 151 | # If true, an OpenSearch description file will be output, and all pages will 152 | # contain a tag referring to it. The value of this option must be the 153 | # base URL from which the finished HTML is served. 154 | #html_use_opensearch = '' 155 | 156 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 157 | #html_file_suffix = None 158 | 159 | # Output file base name for HTML help builder. 160 | htmlhelp_basename = 'sorlthumbnaildoc' 161 | 162 | 163 | # -- Options for LaTeX output -------------------------------------------------- 164 | 165 | # The paper size ('letter' or 'a4'). 166 | #latex_paper_size = 'letter' 167 | 168 | # The font size ('10pt', '11pt' or '12pt'). 169 | #latex_font_size = '10pt' 170 | 171 | # Grouping the document tree into LaTeX files. List of tuples 172 | # (source start file, target name, title, author, documentclass [howto/manual]). 173 | latex_documents = [ 174 | ('index', 'sorlthumbnail.tex', 'sorl-thumbnail Documentation', 175 | 'Mikko Hellsing', 'manual'), 176 | ] 177 | 178 | # The name of an image file (relative to this directory) to place at the top of 179 | # the title page. 180 | #latex_logo = None 181 | 182 | # For "manual" documents, if this is true, then toplevel headings are parts, 183 | # not chapters. 184 | #latex_use_parts = False 185 | 186 | # If true, show page references after internal links. 187 | #latex_show_pagerefs = False 188 | 189 | # If true, show URL addresses after external links. 190 | #latex_show_urls = False 191 | 192 | # Additional stuff for the LaTeX preamble. 193 | #latex_preamble = '' 194 | 195 | # Documents to append as an appendix to all manuals. 196 | #latex_appendices = [] 197 | 198 | # If false, no module index is generated. 199 | #latex_domain_indices = True 200 | 201 | 202 | # -- Options for manual page output -------------------------------------------- 203 | 204 | # One entry per manual page. List of tuples 205 | # (source start file, name, description, authors, manual section). 206 | man_pages = [ 207 | ('index', 'sorlthumbnail', 'sorl-thumbnail Documentation', 208 | ['Mikko Hellsing'], 1) 209 | ] 210 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Contributing 3 | ************ 4 | 5 | Feel free to create a new Pull request if you want to propose a new feature 6 | or fix a bug. If you need development support or want to discuss 7 | with other developers, join us in the channel #sorl-thumbnail at freenode.net 8 | 9 | irc://irc.freenode.net/#sorl-thumbnail 10 | 11 | Running testsuit 12 | ================ 13 | 14 | For occasional developers we recommend using `GitHub Actions`_ to run testsuite, 15 | for those who want to run tests locally, read on. 16 | 17 | Since sorl-thumbnail supports a variety of image backends, python and 18 | Django versions, we provide an easy way to test locally across all of them. 19 | We use `Vagrant`_ for simple interaction with virtual machines and 20 | `tox`_ for managing python virtual environments. 21 | 22 | Some dependencies like pgmagick takes a lot of time to compiling. To speed up your 23 | vagrant box you can edit `Vagrant file`_ with mem and cpu or simply install `vagrant-faster`_. 24 | The resulting .tox folder containing all virtualenvs requires ~ 25 | 26 | * `Install Vagrant`_ 27 | * ``cd`` in your source directory 28 | * Run ``vagrant up`` to prepare VM. It will download Ubuntu image and install all necessary dependencies. 29 | * Run ``vagrant ssh`` to log in the VM 30 | * Launch all tests via ``tox`` (will take some time to build envs first time) 31 | 32 | To run only tests against only one configuration use ``-e`` option:: 33 | 34 | tox -e py34-django16-pil 35 | 36 | Py34 stands for python version, 1.6 is Django version and the latter is image library. 37 | For full list of tox environments, see ``tox.ini`` 38 | 39 | You can get away without using Vagrant if you install all packages locally yourself, 40 | however, this is not recommended. 41 | 42 | .. _GitHub Actions: https://github.com/jazzband/sorl-thumbnail/actions 43 | .. _Vagrant: https://www.vagrantup.com/ 44 | .. _tox: https://tox.wiki/ 45 | .. _Install Vagrant: https://www.vagrantup.com/docs/installation 46 | .. _Vagrant file: https://www.vagrantup.com/docs/providers/virtualbox/configuration 47 | .. _vagrant-faster: https://github.com/rdsubhas/vagrant-faster 48 | 49 | Sending pull requests 50 | ===================== 51 | 52 | 1. Fork the repo:: 53 | 54 | git@github.com:jazzband/sorl-thumbnail.git 55 | 56 | 2. Create a branch for your specific changes:: 57 | 58 | $ git checkout master 59 | $ git pull 60 | $ git checkout -b feature/foobar 61 | 62 | To simplify things, please, make one branch per issue (pull request). 63 | It's also important to make sure your branch is up-to-date with upstream master, 64 | so that maintainers can merge changes easily. 65 | 66 | 3. Commit changes. Please update docs, if relevant. 67 | 68 | 4. Don't forget to run tests to check than nothing breaks. 69 | 70 | 5. Ideally, write your own tests for new feature/bug fix. 71 | 72 | 6. Submit a `pull request`_. 73 | 74 | .. _pull request: https://help.github.com/articles/using-pull-requests 75 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | ******** 2 | Examples 3 | ******** 4 | 5 | Template examples 6 | ================= 7 | 8 | .. highlight:: html+django 9 | 10 | All of the examples assume that you first load the ``thumbnail`` template tag in 11 | your template:: 12 | 13 | {% load thumbnail %} 14 | 15 | Simple:: 16 | 17 | {% thumbnail item.image "100x100" crop="center" as im %} 18 | 19 | {% endthumbnail %} 20 | 21 | 22 | Crop using margin filter, x, y aliases:: 23 | 24 | {% thumbnail item.image "100x700" as im %} 25 | 26 | {% endthumbnail %} 27 | 28 | Using external images and advanced cropping:: 29 | 30 | {% thumbnail "http://www.aino.se/media/i/logo.png" "40x40" crop="80% top" as im %} 31 | 32 | {% endthumbnail %} 33 | 34 | Using the empty feature, the empty section is rendered when the source is 35 | resolved to an empty value or an invalid image source, you can think of it as 36 | rendering when the thumbnail becomes undefined:: 37 | 38 | {% thumbnail item.image my_size_string crop="left" as im %} 39 | 40 | {% empty %} 41 |

No image

42 | {% endthumbnail %} 43 | 44 | Nesting tags and setting size (geometry) for width only:: 45 | 46 | {% thumbnail item.image "1000" as big %} 47 | {% thumbnail item.image "50x50" crop="center" as small %} 48 | 49 | {% endthumbnail %} 50 | {% endthumbnail %} 51 | 52 | Setting geometry for height only:: 53 | 54 | {% thumbnail item.image "x300" as im %} 55 | 56 | {% endthumbnail %} 57 | 58 | Setting format and using the is_portrait filter:: 59 | 60 | {% if item.image|is_portrait %} 61 |
62 | {% thumbnail item.image "100" crop="10px 10px" format="PNG" as im %} 63 | 64 | {% endthumbnail %} 65 |
66 | {% else %} 67 |
68 | {% thumbnail item.image "50" crop="bottom" format="PNG" as im %} 69 | 70 | {% endthumbnail %} 71 |
72 |
73 |

Undefined behaviour

74 |
75 | {% endif %} 76 | 77 | Using HTML filter:: 78 | 79 | {{ text|html_thumbnails }} 80 | 81 | Using markdown filter:: 82 | 83 | {{ text|markdown_thumbnails }} 84 | 85 | .. highlight:: python 86 | 87 | Model examples 88 | ============== 89 | Using the ImageField that automatically deletes references to itself in the key 90 | value store and its thumbnail references when deleted:: 91 | 92 | from django.db import models 93 | from sorl.thumbnail import ImageField 94 | 95 | class Item(models.Model): 96 | image = ImageField(upload_to='whatever') 97 | 98 | 99 | .. note:: You do not need to use the ``sorl.thumbnail.ImageField`` to use 100 | ``sorl.thumbnail``. The standard ``django.db.models.ImageField`` is fine 101 | except that using the ``sorl.thumbnail.ImageField`` lets you plugin the 102 | nice admin addition explained in the next section. 103 | 104 | 105 | Another example on how to use ``sorl.thumbnail.ImageField`` in your existing 106 | project with only small code changes:: 107 | 108 | # util/models.py 109 | from django.db.models import * 110 | from sorl.thumbnail import ImageField 111 | 112 | # myapp/models.py 113 | from util import models 114 | 115 | class MyModel(models.Model): 116 | logo = ImageField(upload_to='/dev/null') 117 | 118 | 119 | Admin examples 120 | ============== 121 | Recommended usage using ``sorl.thumbnail.admin.AdminImageMixin`` (note that this requires use of ``sorl.thumbnail.ImageField`` in your models as explained above):: 122 | 123 | # myapp/admin.py 124 | from django.contrib import admin 125 | from myapp.models import MyModel 126 | from sorl.thumbnail.admin import AdminImageMixin 127 | 128 | class MyModelAdmin(AdminImageMixin, admin.ModelAdmin): 129 | pass 130 | 131 | And the same thing For inlines:: 132 | 133 | # myapp/admin.py 134 | from django.contrib import admin 135 | from myapp.models import MyModel, MyInlineModel 136 | from sorl.thumbnail.admin import AdminImageMixin 137 | 138 | class MyInlineModelAdmin(AdminImageMixin, admin.TabularInline): 139 | model = MyInlineModel 140 | 141 | class MyModelAdmin(admin.ModelAdmin): 142 | inlines = [MyInlineModelAdmin] 143 | 144 | Easy to plugin solution example with little code to change:: 145 | 146 | # util/admin.py 147 | from django.contrib.admin import * 148 | from sorl.thumbnail.admin import AdminImageMixin 149 | 150 | class ModelAdmin(AdminImageMixin, ModelAdmin): 151 | pass 152 | 153 | class TabularInline(AdminImageMixin, TabularInline): 154 | pass 155 | 156 | class StackedInline(AdminImageMixin, StackedInline): 157 | pass 158 | 159 | # myapp/admin.py 160 | from util import admin 161 | from myapp.models import MyModel 162 | 163 | class MyModelAdmin(admin.ModelAdmin): 164 | pass 165 | 166 | 167 | Low level API examples 168 | ====================== 169 | How to get make a thumbnail in your python code:: 170 | 171 | from sorl.thumbnail import get_thumbnail 172 | 173 | im = get_thumbnail(my_file, '100x100', crop='center', quality=99) 174 | 175 | 176 | How to delete a file, its thumbnails as well as references in the Key Value 177 | Store:: 178 | 179 | from sorl.thumbnail import delete 180 | 181 | delete(my_file) 182 | 183 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ****************************** 2 | sorl-thumbnail's documentation 3 | ****************************** 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | examples 11 | installation 12 | requirements 13 | template 14 | management 15 | logging 16 | operation 17 | reference/index 18 | contributing 19 | 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | 28 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ******************** 2 | Installation & Setup 3 | ******************** 4 | 5 | Installation 6 | ============ 7 | First you need to make sure to read the :doc:`requirements`. To install 8 | sorl-thumbnail:: 9 | 10 | pip install sorl-thumbnail 11 | 12 | Depending of the chosen image backend, you may also use one of:: 13 | 14 | pip install sorl-thumbnail[pil] 15 | pip install sorl-thumbnail[wand] 16 | pip install sorl-thumbnail[pgmagick] 17 | 18 | Setup 19 | ===== 20 | 21 | .. highlight:: python 22 | 23 | 1. Add ``sorl.thumbnail`` to your ``settings.INSTALLED_APPS``. 24 | 2. Configure your ``settings`` 25 | 3. If you are using the cached database key value store you need to sync the 26 | database:: 27 | 28 | python manage.py migrate 29 | 30 | -------------------------------------------------------------------------------- /docs/logging.rst: -------------------------------------------------------------------------------- 1 | **************** 2 | Errors & Logging 3 | **************** 4 | 5 | .. highlight:: python 6 | 7 | Background 8 | ========== 9 | When ``THUMBNAIL_DEBUG = False`` errors will be suppressed if they are raised 10 | during rendering the ``thumbnail`` tag or raised within the included filters. 11 | This is the recommended production setting. However it can still be useful to be 12 | notified of those errors. Thus sorl-thumbnail logs errors to a logger and 13 | provides a log handler that sends emails to ``settings.ADMINS``. 14 | 15 | 16 | How to setup logging 17 | ==================== 18 | To enable logging you need to add a handler to the 'sorl.thumbnail' logger. 19 | The following example adds the provided handler that sends emails to site admins 20 | in case an error is raised with debugging off:: 21 | 22 | import logging 23 | from sorl.thumbnail.log import ThumbnailLogHandler 24 | 25 | 26 | handler = ThumbnailLogHandler() 27 | handler.setLevel(logging.ERROR) 28 | logging.getLogger('sorl.thumbnail').addHandler(handler) 29 | 30 | 31 | You will need to load this code somewhere in your django project, it could be 32 | in urls.py, settings.py or project/app __init__.py file for example. You could 33 | of course also provide your own logging handler. 34 | 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 47 | goto end 48 | ) 49 | 50 | if "%1" == "dirhtml" ( 51 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 52 | echo. 53 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 54 | goto end 55 | ) 56 | 57 | if "%1" == "singlehtml" ( 58 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 59 | echo. 60 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 61 | goto end 62 | ) 63 | 64 | if "%1" == "pickle" ( 65 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 66 | echo. 67 | echo.Build finished; now you can process the pickle files. 68 | goto end 69 | ) 70 | 71 | if "%1" == "json" ( 72 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 73 | echo. 74 | echo.Build finished; now you can process the JSON files. 75 | goto end 76 | ) 77 | 78 | if "%1" == "htmlhelp" ( 79 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 80 | echo. 81 | echo.Build finished; now you can run HTML Help Workshop with the ^ 82 | .hhp project file in %BUILDDIR%/htmlhelp. 83 | goto end 84 | ) 85 | 86 | if "%1" == "qthelp" ( 87 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 88 | echo. 89 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 90 | .qhcp project file in %BUILDDIR%/qthelp, like this: 91 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\sorlthumbnail.qhcp 92 | echo.To view the help file: 93 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\sorlthumbnail.ghc 94 | goto end 95 | ) 96 | 97 | if "%1" == "devhelp" ( 98 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 99 | echo. 100 | echo.Build finished. 101 | goto end 102 | ) 103 | 104 | if "%1" == "epub" ( 105 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 106 | echo. 107 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 108 | goto end 109 | ) 110 | 111 | if "%1" == "latex" ( 112 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 113 | echo. 114 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 115 | goto end 116 | ) 117 | 118 | if "%1" == "text" ( 119 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 120 | echo. 121 | echo.Build finished. The text files are in %BUILDDIR%/text. 122 | goto end 123 | ) 124 | 125 | if "%1" == "man" ( 126 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 127 | echo. 128 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 129 | goto end 130 | ) 131 | 132 | if "%1" == "changes" ( 133 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 134 | echo. 135 | echo.The overview file is in %BUILDDIR%/changes. 136 | goto end 137 | ) 138 | 139 | if "%1" == "linkcheck" ( 140 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 141 | echo. 142 | echo.Link check complete; look for any errors in the above output ^ 143 | or in %BUILDDIR%/linkcheck/output.txt. 144 | goto end 145 | ) 146 | 147 | if "%1" == "doctest" ( 148 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 149 | echo. 150 | echo.Testing of doctests in the sources finished, look at the ^ 151 | results in %BUILDDIR%/doctest/output.txt. 152 | goto end 153 | ) 154 | 155 | :end 156 | -------------------------------------------------------------------------------- /docs/management.rst: -------------------------------------------------------------------------------- 1 | ******************* 2 | Management commands 3 | ******************* 4 | 5 | .. highlight:: python 6 | 7 | .. _thumbnail-cleanup: 8 | 9 | thumbnail cleanup 10 | ================= 11 | ``python manage.py thumbnail cleanup`` 12 | 13 | This cleans up the Key Value Store from stale cache. It removes references to 14 | images that do not exist and thumbnail references and their actual files for 15 | images that do not exist. It removes thumbnails for unknown images. 16 | 17 | 18 | .. _thumbnail-clear: 19 | 20 | thumbnail clear 21 | =============== 22 | ``python manage.py thumbnail clear`` 23 | 24 | This totally empties the Key Value Store of all keys that start with the 25 | ``settings.THUMBNAIL_KEY_PREFIX``. It does not delete any files. The Key Value 26 | store will update when you hit the template tags, and if the thumbnails files 27 | still exist they will be used and not overwritten/regenerated. This can be 28 | useful if your Key Value Store has garbage data not dealt with by cleanup or 29 | you're switching Key Value Store backend. 30 | 31 | 32 | .. _thumbnail-clear-delete-referenced: 33 | 34 | thumbnail clear_delete_referenced 35 | ================================= 36 | ``python manage.py thumbnail clear_delete_referenced`` 37 | 38 | Equivalent to ``clear`` but first it will delete all thumbnail files 39 | referenced by the Key Value Store. It is generally safe to run this if you do 40 | not reference the generated thumbnails by name somewhere else in your code. As 41 | long as all the original images still exist this will trigger a regeneration of 42 | all the thumbnails the Key Value Store knows about. 43 | 44 | 45 | .. _thumbnail-clear-delete-all: 46 | 47 | thumbnail clear_delete_all 48 | ========================== 49 | ``python manage.py thumbnail clear_delete_all`` 50 | 51 | Equivalent to to ``clear`` but afterwards it will delete all thumbnail files 52 | including any orphans not in the Key Value Store. This can be thought of as a 53 | more aggressive version of ``clear_delete_referenced``. Caution should be 54 | exercised with this command if multiple Django sites (as in ``SITE_ID``) or 55 | projects are using the same ``MEDIA_ROOT`` since this will clear out absolutely 56 | everything in the thumbnail cache directory causing thumbnail regeneration for 57 | all sites and projects. When file system storage is used, it is equivalent to 58 | ``rm -rf MEDIA_ROOT + THUMBNAIL_PREFIX`` 59 | -------------------------------------------------------------------------------- /docs/operation.rst: -------------------------------------------------------------------------------- 1 | *************************** 2 | How sorl-thumbnail operates 3 | *************************** 4 | 5 | .. highlight:: python 6 | 7 | When you use the ``thumbnail`` template tag sorl-thumbnail looks up the 8 | thumbnail in a :ref:`kvstore-requirements`. The key for a thumbnail is 9 | generated from its filename and storage. The thumbnail filename in turn is 10 | generated from the source and requested thumbnail size and options. If the key 11 | for the thumbnail is found in the |kvstore|, the serialized thumbnail 12 | information is fetched from it and returned. If the thumbnail key is not found 13 | there sorl-thumbnail continues to generate the thumbnail and stores necessary 14 | information in the |kvstore|. It is worth noting that sorl-thumbnail does not 15 | check if source or thumbnail exists if the thumbnail key is found in the 16 | |kvstore|. 17 | 18 | .. note:: This means that if you change or delete a source file or delete the 19 | thumbnail, sorl-thumbnail will still fetch from the |kvstore|. 20 | Therefore it is important that if you delete or change a source or 21 | thumbnail file notify the |kvstore|. 22 | 23 | If you change or delete a source or a thumbnail for some reason, you can use 24 | the ``delete`` method of the ``ThumbnailBackend`` class or subclass:: 25 | 26 | from sorl.thumbnail import delete 27 | 28 | # Delete the Key Value Store reference but **not** the file. 29 | # Use this if you have changed the source 30 | delete(my_file, delete_file=False) 31 | 32 | # Delete the Key Value Store reference and the file 33 | # Use this if you want to delete the source file 34 | delete(my_file) # delete_file=True is default 35 | 36 | 37 | The ``sorl.thumbnail.delete`` method always deletes the input files thumbnail 38 | Key Value Store references as well as thumbnail files. You can use this method 39 | on thumbnails as well as source files. Alternatively if you have **deleted** a 40 | file you can use the management command :ref:`thumbnail-cleanup`. Deleting an 41 | image using the ``sorl.thumbnail.ImageField`` will notify the |kvstore| to 42 | delete references to it and delete all of its thumbnail references and files, 43 | exactly like the above code example. 44 | 45 | **Why you ask?** Why go through all the trouble with a |kvstore| and risk 46 | stale cache? Why not use a database to cache if you are going to do that? 47 | 48 | The reason is speed and especially with storages other than local file storage. 49 | Checking if a file exists before serving it will cost too much. Speed is also 50 | the reason for not choosing to use a standard database for this kind of 51 | persistent caching. However sorl-thumbnail does ship with a *cached* database 52 | |kvstore|. 53 | 54 | .. note:: We have to assume the thumbnail exists if the thumbnail key exists in 55 | the |kvstore| 56 | 57 | **There are bonuses**. We can store meta data in the |kvstore| that would be 58 | too costly to retrieve even for local file storage. Today this meta data 59 | consists only of the image size but this could be expanded to for example EXIF 60 | data. The other bonus is that we can keep track of what thumbnails has been 61 | generated from a particular source and deleting them too when the source is 62 | deleted. 63 | 64 | `Schematic view of how things are done 65 | `_ 66 | 67 | .. |kvstore| replace:: Key Value Store 68 | 69 | -------------------------------------------------------------------------------- /docs/reference/image.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | ImageFile 3 | ********* 4 | 5 | .. highlight:: html+django 6 | 7 | ``ImageFile`` is an image abstraction that contains useful attributes when 8 | working with images. The ``thumbnail`` template tag puts the generated thumbnail 9 | in context as an ``ImageFile`` instance. In the following example:: 10 | 11 | {% thumbnail item.image "100x100" as im %} 12 | 13 | {% endthumbnail %} 14 | 15 | ``im`` will be an ``ImageFile`` instance. 16 | 17 | .. highlight:: python 18 | 19 | ImageFile attributes 20 | ==================== 21 | 22 | ``name`` 23 | -------- 24 | Name of the image as returned from the underlying storage. 25 | 26 | ``storage`` 27 | ----------- 28 | Returns the storage instance. 29 | 30 | ``width`` 31 | --------- 32 | Returns the width of the image in pixels. 33 | 34 | ``x`` 35 | ----- 36 | Alias of ``width`` 37 | 38 | ``height`` 39 | ---------- 40 | Returns the height of the image in pixels. 41 | 42 | ``y`` 43 | ----- 44 | Alias of ``height`` 45 | 46 | ``ratio`` 47 | --------- 48 | Returns the image ratio (y/x) as a float 49 | 50 | ``url`` 51 | ------- 52 | URL of the image url as returned by the underlying storage. 53 | 54 | ``src`` 55 | ------- 56 | Alias of ``url`` 57 | 58 | ``size`` 59 | -------- 60 | Returns the image size in pixels as a (x, y) tuple 61 | 62 | ``key`` 63 | ------- 64 | Returns a unique key based on ``name`` and ``storage``. 65 | 66 | 67 | ImageFile methods 68 | ================= 69 | 70 | ``exists`` 71 | ---------- 72 | Returns whether the file exists as returned by the underlying storage. 73 | 74 | ``is_portrait`` 75 | --------------- 76 | Returns ``True`` if ``y > x``, else ``False`` 77 | 78 | ``set_size`` 79 | ------------ 80 | Sets the size of the image, takes an optional size tuple (x, y) as argument. 81 | 82 | ``read`` 83 | -------- 84 | Reads the file as done from the underlying storage. 85 | 86 | ``write`` 87 | --------- 88 | Writes content to the file. Takes content as argument. Content is either raw 89 | data or an instance of ``django.core.files.base.ContentFile``. 90 | 91 | ``delete`` 92 | ---------- 93 | Deletes the file from underlying storage. 94 | 95 | ``serialize`` 96 | ------------- 97 | Returns a serialized version of self. 98 | 99 | ``serialize_storage`` 100 | --------------------- 101 | Returns the ``self.storage`` as a serialized dot name path string. 102 | 103 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Reference 3 | ********* 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | image 9 | settings 10 | 11 | -------------------------------------------------------------------------------- /docs/requirements.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Requirements 3 | ************ 4 | 5 | Base requirements 6 | ================= 7 | - `Python`_ 3.9+ 8 | - `Django`_ 9 | - :ref:`kvstore-requirements` 10 | - :ref:`image-library` 11 | 12 | .. _kvstore-requirements: 13 | 14 | Key Value Store 15 | =============== 16 | sorl-thumbnail needs a Key Value Store for its operation. You can choose between 17 | a **cached database** which requires no special installation to your normal 18 | Django setup besides installing a proper cache like memcached **or** you can 19 | setup **redis** which requires a little bit more work. 20 | 21 | Since Django 4.0, the Redis cache can be configured at Django level, so any 22 | alternative Key Value Store in sorl-thumbnail is now deprecated. 23 | 24 | Cached DB 25 | --------- 26 | All you need to use the cached database key value store is a database and `cache 27 | `_ setup properly using 28 | memcached. This cache needs to be really fast so **using anything else than 29 | memcached is not recommended**. 30 | 31 | Redis 32 | ----- 33 | Redis is a fast key value store also suited for the job. To use the `redis`_ key 34 | value store you first need to install the `redis server 35 | `_. After that install the `redis client 36 | `_:: 37 | 38 | pip install redis 39 | 40 | 41 | .. _image-library: 42 | 43 | Image Library 44 | ============= 45 | You need to have an image library installed. sorl-thumbnail ships with support 46 | for `Python Imaging Library`_, `pgmagick`_, `ImageMagick`_ (or `GraphicsMagick`) 47 | command line tools. `pgmagick`_ are python bindings for `GraphicsMagick`_ 48 | (Magick++)`, 49 | 50 | The `ImageMagick`_ based engine ``sorl.thumbnail.engines.convert_engine.Engine`` 51 | by default calls ``convert`` and ``identify`` shell commands. You can change the 52 | paths to these tools by setting ``THUMBNAIL_CONVERT`` and ``THUMBNAIL_IDENTIFY`` 53 | respectively. Note that you need to change these to use `GraphicsMagick`_ to 54 | ``/path/to/gm convert`` and ``/path/to/gm identify``. 55 | 56 | Python Imaging Library installation 57 | ----------------------------------- 58 | Prerequisites: 59 | 60 | - libjpeg 61 | - zlib 62 | 63 | Ubuntu 10.04 package installation:: 64 | 65 | sudo apt-get install libjpeg62 libjpeg62-dev zlib1g-dev 66 | 67 | Installing `Python Imaging Library`_ using pip:: 68 | 69 | pip install Pillow 70 | 71 | Watch the output for messages on what support got compiled in, you at least 72 | want to see the following:: 73 | 74 | --- JPEG support available 75 | --- ZLIB (PNG/ZIP) support available 76 | 77 | pgmagick installation 78 | --------------------- 79 | Prerequisites: 80 | 81 | - GraphicsMagick 82 | - Boost.Python 83 | 84 | Ubuntu 10.04 package installation:: 85 | 86 | sudo apt-get install libgraphicsmagick++-dev 87 | sudo apt-get install libboost-python1.40-dev 88 | 89 | Fedora installation:: 90 | 91 | yum install GraphicsMagick-c++-devel 92 | yum install boost-devel 93 | 94 | Installing `pgmagick`_ using pip:: 95 | 96 | pip install pgmagick 97 | 98 | ImageMagick installation 99 | ------------------------ 100 | Ubuntu 10.04 package installation:: 101 | 102 | sudo apt-get install imagemagick 103 | 104 | Or if you prefer `GraphicsMagick`_:: 105 | 106 | sudo apt-get install graphicsmagick 107 | 108 | Wand installation 109 | ------------------------ 110 | 111 | Ubuntu installation:: 112 | 113 | apt-get install libmagickwand-dev 114 | pip install Wand 115 | 116 | 117 | .. _Python Imaging Library: https://python-pillow.org/ 118 | .. _ImageMagick: https://imagemagick.org/ 119 | .. _GraphicsMagick: http://www.graphicsmagick.org/ 120 | .. _redis: https://redis.io/ 121 | .. _redis-py: https://github.com/redis/redis-py 122 | .. _Django: https://www.djangoproject.com/ 123 | .. _Python: https://www.python.org/ 124 | .. _pgmagick: https://pgmagick.readthedocs.io/ 125 | .. _wand: https://docs.wand-py.org 126 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools_scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "sorl-thumbnail" 7 | dynamic = ["version"] 8 | description = "Thumbnails for Django" 9 | readme = "README.rst" 10 | license = {file = "LICENSE"} 11 | keywords = ["django", "thumbnail", "sorl"] 12 | authors = [ 13 | {name = "Mikko Hellsing", email = "mikko@aino.se"}, 14 | ] 15 | maintainers = [ 16 | {name = "Jazzband", email = "roadies@jazzband.co"} 17 | ] 18 | requires-python = ">= 3.9" 19 | classifiers=[ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Environment :: Web Environment', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 3.9', 27 | 'Programming Language :: Python :: 3.10', 28 | 'Programming Language :: Python :: 3.11', 29 | 'Programming Language :: Python :: 3.12', 30 | 'Programming Language :: Python :: 3.13', 31 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 32 | 'Topic :: Multimedia :: Graphics', 33 | 'Framework :: Django', 34 | 'Framework :: Django :: 4.2', 35 | 'Framework :: Django :: 5.0', 36 | 'Framework :: Django :: 5.1', 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://sorl-thumbnail.readthedocs.io/en/latest/" 41 | Repository = "https://github.com/jazzband/sorl-thumbnail" 42 | 43 | [project.optional-dependencies] 44 | pgmagick = ["pgmagick"] 45 | pil = ["pillow"] 46 | wand = ["wand"] 47 | 48 | [tool.coverage.run] 49 | source = ["sorl"] 50 | omit = [ 51 | "*/sorl-thumbnail/sorl/__init__.py", 52 | "*/sorl/thumbnail/__init__.py", 53 | "*/sorl/thumbnail/conf/__init__.py", 54 | "*/sorl/thumbnail/admin/__init__.py", 55 | ] 56 | 57 | [tool.coverage.report] 58 | exclude_lines = [ 59 | "pragma: no cover", 60 | "if __name__ == .__main__.:", 61 | ] 62 | 63 | # below line is required to generating versions with setuptools_scm 64 | [tool.setuptools_scm] 65 | 66 | [tool.ruff] 67 | exclude = [ 68 | ".tox", 69 | "docs/*", 70 | "*/migrations/*", 71 | "tests/settings/*", 72 | "sorl/thumbnail/__init__.py", 73 | "sorl/thumbnail/admin/__init__.py" 74 | ] 75 | line-length = 100 76 | 77 | [tool.ruff.lint] 78 | select = ["B", "C901", "E", "F", "I", "W"] 79 | ignore = ["B904", "B017"] 80 | 81 | [tool.ruff.lint.mccabe] 82 | max-complexity = 15 83 | -------------------------------------------------------------------------------- /sorl/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version("sorl-thumbnail") 5 | except PackageNotFoundError: 6 | # package is not installed 7 | pass 8 | -------------------------------------------------------------------------------- /sorl/thumbnail/__init__.py: -------------------------------------------------------------------------------- 1 | from sorl.thumbnail.fields import ImageField 2 | from sorl.thumbnail.shortcuts import get_thumbnail, delete 3 | 4 | -------------------------------------------------------------------------------- /sorl/thumbnail/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from django.forms import ClearableFileInput 2 | from .current import AdminImageMixin 3 | 4 | AdminInlineImageMixin = AdminImageMixin # backwards compatibility 5 | -------------------------------------------------------------------------------- /sorl/thumbnail/admin/current.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django import forms 4 | from django.utils.safestring import mark_safe 5 | 6 | from sorl.thumbnail.fields import ImageField 7 | from sorl.thumbnail.shortcuts import get_thumbnail 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class AdminImageWidget(forms.ClearableFileInput): 13 | """ 14 | An ImageField Widget for django.contrib.admin that shows a thumbnailed 15 | image as well as a link to the current one if it hase one. 16 | """ 17 | 18 | template_with_initial = ( 19 | '%(clear_template)s
' 20 | '' 21 | ) 22 | template_with_clear = '' 23 | 24 | def render(self, name, value, attrs=None, **kwargs): 25 | output = super().render(name, value, attrs, **kwargs) 26 | if value and hasattr(value, 'url'): 27 | ext = 'JPEG' 28 | try: 29 | aux_ext = str(value).split('.') 30 | if aux_ext[len(aux_ext) - 1].lower() == 'png': 31 | ext = 'PNG' 32 | elif aux_ext[len(aux_ext) - 1].lower() == 'gif': 33 | ext = 'GIF' 34 | except Exception: 35 | pass 36 | try: 37 | mini = get_thumbnail(value, 'x80', upscale=False, format=ext) 38 | except Exception as e: 39 | logger.warning("Unable to get the thumbnail", exc_info=e) 40 | else: 41 | try: 42 | output = ( 43 | '
' 44 | '' 46 | '%s
' 47 | ) % (mini.width, value.url, mini.url, output) 48 | except (AttributeError, TypeError): 49 | pass 50 | return mark_safe(output) 51 | 52 | 53 | class AdminImageMixin: 54 | """ 55 | This is a mix-in for InlineModelAdmin subclasses to make ``ImageField`` 56 | show nicer form widget 57 | """ 58 | 59 | def formfield_for_dbfield(self, db_field, request, **kwargs): 60 | if isinstance(db_field, ImageField): 61 | return db_field.formfield(widget=AdminImageWidget) 62 | return super().formfield_for_dbfield(db_field, request, **kwargs) 63 | -------------------------------------------------------------------------------- /sorl/thumbnail/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | from sorl.thumbnail import default 6 | from sorl.thumbnail.conf import defaults as default_settings 7 | from sorl.thumbnail.conf import settings 8 | from sorl.thumbnail.helpers import serialize, tokey 9 | from sorl.thumbnail.images import DummyImageFile, ImageFile 10 | from sorl.thumbnail.parsers import parse_geometry 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | EXTENSIONS = { 15 | 'JPEG': 'jpg', 16 | 'PNG': 'png', 17 | 'GIF': 'gif', 18 | 'WEBP': 'webp', 19 | } 20 | 21 | 22 | class ThumbnailBackend: 23 | """ 24 | The main class for sorl-thumbnail, you can subclass this if you for example 25 | want to change the way destination filename is generated. 26 | """ 27 | 28 | default_options = { 29 | 'format': settings.THUMBNAIL_FORMAT, 30 | 'quality': settings.THUMBNAIL_QUALITY, 31 | 'colorspace': settings.THUMBNAIL_COLORSPACE, 32 | 'upscale': settings.THUMBNAIL_UPSCALE, 33 | 'crop': False, 34 | 'cropbox': None, 35 | 'rounded': None, 36 | 'padding': settings.THUMBNAIL_PADDING, 37 | 'padding_color': settings.THUMBNAIL_PADDING_COLOR, 38 | } 39 | 40 | extra_options = ( 41 | ('progressive', 'THUMBNAIL_PROGRESSIVE'), 42 | ('orientation', 'THUMBNAIL_ORIENTATION'), 43 | ('blur', 'THUMBNAIL_BLUR'), 44 | ) 45 | 46 | def file_extension(self, source): 47 | return os.path.splitext(source.name)[1].lower() 48 | 49 | def _get_format(self, source): 50 | file_extension = self.file_extension(source) 51 | 52 | if file_extension == '.jpg' or file_extension == '.jpeg': 53 | return 'JPEG' 54 | elif file_extension == '.png': 55 | return 'PNG' 56 | elif file_extension == '.gif': 57 | return 'GIF' 58 | elif file_extension == '.webp': 59 | return 'WEBP' 60 | else: 61 | from django.conf import settings 62 | 63 | return getattr(settings, 'THUMBNAIL_FORMAT', default_settings.THUMBNAIL_FORMAT) 64 | 65 | def get_thumbnail(self, file_, geometry_string, **options): 66 | """ 67 | Returns thumbnail as an ImageFile instance for file with geometry and 68 | options given. First it will try to get it from the key value store, 69 | secondly it will create it. 70 | """ 71 | logger.debug('Getting thumbnail for file [%s] at [%s]', file_, geometry_string) 72 | 73 | if file_: 74 | source = ImageFile(file_) 75 | else: 76 | raise ValueError('falsey file_ argument in get_thumbnail()') 77 | 78 | # preserve image filetype 79 | if settings.THUMBNAIL_PRESERVE_FORMAT: 80 | options.setdefault('format', self._get_format(source)) 81 | 82 | for key, value in self.default_options.items(): 83 | options.setdefault(key, value) 84 | 85 | # For the future I think it is better to add options only if they 86 | # differ from the default settings as below. This will ensure the same 87 | # filenames being generated for new options at default. 88 | for key, attr in self.extra_options: 89 | value = getattr(settings, attr) 90 | if value != getattr(default_settings, attr): 91 | options.setdefault(key, value) 92 | 93 | name = self._get_thumbnail_filename(source, geometry_string, options) 94 | thumbnail = ImageFile(name, default.storage) 95 | cached = default.kvstore.get(thumbnail) 96 | 97 | if cached: 98 | return cached 99 | 100 | # We have to check exists() because the Storage backend does not 101 | # overwrite in some implementations. 102 | if settings.THUMBNAIL_FORCE_OVERWRITE or not thumbnail.exists(): 103 | try: 104 | source_image = default.engine.get_image(source) 105 | except Exception as e: 106 | logger.exception(e) 107 | if settings.THUMBNAIL_DUMMY: 108 | return DummyImageFile(geometry_string) 109 | else: 110 | # if storage backend says file doesn't exist remotely, 111 | # don't try to create it and exit early. 112 | # Will return working empty image type; 404'd image 113 | logger.warning( 114 | 'Remote file [%s] at [%s] does not exist', 115 | file_, geometry_string, 116 | ) 117 | return thumbnail 118 | 119 | # We might as well set the size since we have the image in memory 120 | image_info = default.engine.get_image_info(source_image) 121 | options['image_info'] = image_info 122 | size = default.engine.get_image_size(source_image) 123 | source.set_size(size) 124 | 125 | try: 126 | self._create_thumbnail(source_image, geometry_string, options, 127 | thumbnail) 128 | self._create_alternative_resolutions(source_image, geometry_string, 129 | options, thumbnail.name) 130 | finally: 131 | default.engine.cleanup(source_image) 132 | 133 | # If the thumbnail exists we don't create it, the other option is 134 | # to delete and write but this could lead to race conditions so I 135 | # will just leave that out for now. 136 | default.kvstore.get_or_set(source) 137 | default.kvstore.set(thumbnail, source) 138 | return thumbnail 139 | 140 | def delete(self, file_, delete_file=True): 141 | """ 142 | Deletes file_ references in Key Value store and optionally the file_ 143 | it self. 144 | """ 145 | image_file = ImageFile(file_) 146 | if delete_file: 147 | image_file.delete() 148 | default.kvstore.delete(image_file) 149 | 150 | def _create_thumbnail(self, source_image, geometry_string, options, 151 | thumbnail): 152 | """ 153 | Creates the thumbnail by using default.engine 154 | """ 155 | logger.debug('Creating thumbnail file [%s] at [%s] with [%s]', 156 | thumbnail.name, geometry_string, options) 157 | ratio = default.engine.get_image_ratio(source_image, options) 158 | geometry = parse_geometry(geometry_string, ratio) 159 | image = default.engine.create(source_image, geometry, options) 160 | default.engine.write(image, options, thumbnail) 161 | # It's much cheaper to set the size here 162 | size = default.engine.get_image_size(image) 163 | thumbnail.set_size(size) 164 | 165 | def _create_alternative_resolutions(self, source_image, geometry_string, 166 | options, name): 167 | """ 168 | Creates the thumbnail by using default.engine with multiple output 169 | sizes. Appends @x to the file name. 170 | """ 171 | ratio = default.engine.get_image_ratio(source_image, options) 172 | geometry = parse_geometry(geometry_string, ratio) 173 | file_name, dot_file_ext = os.path.splitext(name) 174 | 175 | for resolution in settings.THUMBNAIL_ALTERNATIVE_RESOLUTIONS: 176 | resolution_geometry = (int(geometry[0] * resolution), int(geometry[1] * resolution)) 177 | resolution_options = options.copy() 178 | if 'crop' in options and isinstance(options['crop'], str): 179 | crop = options['crop'].split(" ") 180 | for i in range(len(crop)): 181 | s = re.match(r"(\d+)px", crop[i]) 182 | if s: 183 | crop[i] = "%spx" % int(int(s.group(1)) * resolution) 184 | resolution_options['crop'] = " ".join(crop) 185 | 186 | image = default.engine.create(source_image, resolution_geometry, options) 187 | thumbnail_name = '%(file_name)s%(suffix)s%(file_ext)s' % { 188 | 'file_name': file_name, 189 | 'suffix': '@%sx' % resolution, 190 | 'file_ext': dot_file_ext 191 | } 192 | thumbnail = ImageFile(thumbnail_name, default.storage) 193 | default.engine.write(image, resolution_options, thumbnail) 194 | size = default.engine.get_image_size(image) 195 | thumbnail.set_size(size) 196 | 197 | def _get_thumbnail_filename(self, source, geometry_string, options): 198 | """ 199 | Computes the destination filename. 200 | """ 201 | key = tokey(source.key, geometry_string, serialize(options)) 202 | # make some subdirs 203 | path = '%s/%s/%s' % (key[:2], key[2:4], key) 204 | return '%s%s.%s' % (settings.THUMBNAIL_PREFIX, path, EXTENSIONS[options['format']]) 205 | -------------------------------------------------------------------------------- /sorl/thumbnail/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings as user_settings 2 | 3 | from sorl.thumbnail.conf import defaults 4 | 5 | 6 | class Settings: 7 | """ 8 | Settings proxy that will lookup first in the django settings, and then in the conf 9 | defaults. 10 | """ 11 | def __getattr__(self, name): 12 | if name != name.upper(): 13 | raise AttributeError(name) 14 | try: 15 | return getattr(user_settings, name) 16 | except AttributeError: 17 | return getattr(defaults, name) 18 | 19 | 20 | settings = Settings() 21 | -------------------------------------------------------------------------------- /sorl/thumbnail/conf/defaults.py: -------------------------------------------------------------------------------- 1 | # When True ThumbnailNode.render can raise errors 2 | THUMBNAIL_DEBUG = False 3 | 4 | # Backend 5 | THUMBNAIL_BACKEND = 'sorl.thumbnail.base.ThumbnailBackend' 6 | 7 | # Key-value store, ships with: 8 | # sorl.thumbnail.kvstores.cached_db_kvstore.KVStore 9 | # sorl.thumbnail.kvstores.redis_kvstore.KVStore 10 | # Redis requires some more work, see docs 11 | THUMBNAIL_KVSTORE = 'sorl.thumbnail.kvstores.cached_db_kvstore.KVStore' 12 | 13 | # Change this to something else for MSSQL 14 | THUMBNAIL_KEY_DBCOLUMN = 'key' 15 | 16 | # Engine, ships with: 17 | # sorl.thumbnail.engines.convert_engine.Engine 18 | # sorl.thumbnail.engines.pil_engine.Engine 19 | # sorl.thumbnail.engines.pgmagick_engine.Engine 20 | # convert is preferred but requires imagemagick or graphicsmagick, se docs 21 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.pil_engine.Engine' 22 | 23 | # Path to Imagemagick or Graphicsmagick ``convert`` and ``identify``. 24 | THUMBNAIL_CONVERT = 'convert' 25 | THUMBNAIL_IDENTIFY = 'identify' 26 | 27 | # Path to ``vipsthumbnail`` and ``vipsheader`` 28 | THUMBNAIL_VIPSTHUMBNAIL = 'vipsthumbnail' 29 | THUMBNAIL_VIPSHEADER = 'vipsheader' 30 | 31 | # Storage for the generated thumbnails, as an alias of the Django STORAGES setting. 32 | THUMBNAIL_STORAGE = 'default' 33 | 34 | # Redis settings 35 | THUMBNAIL_REDIS_DB = 0 36 | THUMBNAIL_REDIS_PASSWORD = '' 37 | THUMBNAIL_REDIS_HOST = 'localhost' 38 | THUMBNAIL_REDIS_PORT = 6379 39 | THUMBNAIL_REDIS_UNIX_SOCKET_PATH = None 40 | THUMBNAIL_REDIS_SSL = False 41 | THUMBNAIL_REDIS_TIMEOUT = 3600 * 24 * 365 * 10 # 10 years 42 | 43 | # DBM settings 44 | THUMBNAIL_DBM_FILE = "thumbnail_kvstore" 45 | THUMBNAIL_DBM_MODE = 0o644 46 | 47 | # Cache timeout for ``cached_db`` store. You should probably keep this at 48 | # maximum or ``0`` if your caching backend can handle that as infinite. 49 | THUMBNAIL_CACHE_TIMEOUT = 3600 * 24 * 365 * 10 # 10 years 50 | 51 | # The cache configuration to use for storing thumbnail data 52 | THUMBNAIL_CACHE = 'default' 53 | 54 | # Key prefix used by the key value store 55 | THUMBNAIL_KEY_PREFIX = 'sorl-thumbnail' 56 | 57 | # Thumbnail filename prefix 58 | THUMBNAIL_PREFIX = 'cache/' 59 | 60 | # Image format, common formats are: JPEG, PNG, GIF 61 | # Make sure the backend can handle the format you specify 62 | THUMBNAIL_FORMAT = 'JPEG' 63 | 64 | THUMBNAIL_PRESERVE_FORMAT = False 65 | 66 | # Colorspace, backends are required to implement: RGB, GRAY 67 | # Setting this to None will keep the original colorspace. 68 | THUMBNAIL_COLORSPACE = 'RGB' 69 | 70 | # Should we upscale images by default 71 | THUMBNAIL_UPSCALE = True 72 | 73 | # Quality, 0-100 74 | THUMBNAIL_QUALITY = 95 75 | 76 | # Gaussian blur radius 77 | THUMBNAIL_BLUR = 0 78 | 79 | # Adds padding around the image to match the requested size without cropping 80 | THUMBNAIL_PADDING = False 81 | THUMBNAIL_PADDING_COLOR = '#ffffff' 82 | 83 | # Save as progressive when saving as jpeg 84 | THUMBNAIL_PROGRESSIVE = True 85 | 86 | # Orientate the thumbnail with respect to source EXIF orientation tag 87 | THUMBNAIL_ORIENTATION = True 88 | 89 | # This means sorl.thumbnail will generate and serve a generated dummy image 90 | # regardless of the thumbnail source content 91 | THUMBNAIL_DUMMY = False 92 | 93 | # Thumbnail dummy (placeholder) source. Some you might try are: 94 | # http://placekitten.com/%(width)s/%(height)s 95 | # http://placekitten.com/g/%(width)s/%(height)s 96 | # http://placehold.it/%(width)sx%(height)s 97 | THUMBNAIL_DUMMY_SOURCE = 'https://dummyimage.com/%(width)sx%(height)s' 98 | 99 | # Sets the source image ratio for dummy generation of images with only width 100 | # or height given 101 | THUMBNAIL_DUMMY_RATIO = 1.5 102 | 103 | # Enables creation of multiple-resolution (aka "Retina") images. 104 | # We don't create retina images by default to optimize performance. 105 | THUMBNAIL_ALTERNATIVE_RESOLUTIONS = [] 106 | 107 | # Lazy fill empty thumbnail like THUMBNAIL_DUMMY 108 | THUMBNAIL_LAZY_FILL_EMPTY = False 109 | 110 | # Timeout, in seconds, to use when retrieving images with urllib2 111 | THUMBNAIL_URL_TIMEOUT = None 112 | 113 | # Default width when using filters for texts 114 | THUMBNAIL_FILTER_WIDTH = 500 115 | 116 | # Should we flatten images by default (fixes a lot of transparency issues with 117 | # imagemagick) 118 | THUMBNAIL_FLATTEN = False 119 | 120 | # Whenever we will check an existing thumbnail exists and avoid to overwrite or not. 121 | # Set this to true if you have an slow .exists() implementation on your storage backend of choice. 122 | THUMBNAIL_FORCE_OVERWRITE = False 123 | 124 | # Should we remove GET arguments from URLs? (suggested for Amazon S3 image urls) 125 | THUMBNAIL_REMOVE_URL_ARGS = True 126 | -------------------------------------------------------------------------------- /sorl/thumbnail/default.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import storages 2 | from django.utils.functional import LazyObject 3 | 4 | from sorl.thumbnail.conf import settings 5 | from sorl.thumbnail.helpers import get_module_class 6 | 7 | 8 | class Backend(LazyObject): 9 | def _setup(self): 10 | self._wrapped = get_module_class(settings.THUMBNAIL_BACKEND)() 11 | 12 | 13 | class KVStore(LazyObject): 14 | def _setup(self): 15 | self._wrapped = get_module_class(settings.THUMBNAIL_KVSTORE)() 16 | 17 | 18 | class Engine(LazyObject): 19 | def _setup(self): 20 | self._wrapped = get_module_class(settings.THUMBNAIL_ENGINE)() 21 | 22 | 23 | class Storage(LazyObject): 24 | def _setup(self): 25 | if "." in settings.THUMBNAIL_STORAGE: 26 | self._wrapped = get_module_class(settings.THUMBNAIL_STORAGE)() 27 | else: 28 | self._wrapped = storages[settings.THUMBNAIL_STORAGE] 29 | 30 | 31 | backend = Backend() 32 | kvstore = KVStore() 33 | engine = Engine() 34 | storage = Storage() 35 | -------------------------------------------------------------------------------- /sorl/thumbnail/engines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/engines/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/engines/base.py: -------------------------------------------------------------------------------- 1 | from sorl.thumbnail.conf import settings 2 | from sorl.thumbnail.helpers import toint 3 | from sorl.thumbnail.parsers import parse_crop, parse_cropbox 4 | 5 | 6 | class EngineBase: 7 | """ 8 | ABC for Thumbnail engines, methods are static 9 | """ 10 | 11 | def create(self, image, geometry, options): 12 | """ 13 | Processing conductor, returns the thumbnail as an image engine instance 14 | """ 15 | image = self.cropbox(image, geometry, options) 16 | image = self.orientation(image, geometry, options) 17 | image = self.colorspace(image, geometry, options) 18 | image = self.remove_border(image, options) 19 | image = self.scale(image, geometry, options) 20 | image = self.crop(image, geometry, options) 21 | image = self.rounded(image, geometry, options) 22 | image = self.blur(image, geometry, options) 23 | image = self.padding(image, geometry, options) 24 | return image 25 | 26 | def cropbox(self, image, geometry, options): 27 | """ 28 | Wrapper for ``_cropbox`` 29 | """ 30 | cropbox = options['cropbox'] 31 | if not cropbox: 32 | return image 33 | x, y, x2, y2 = parse_cropbox(cropbox) 34 | return self._cropbox(image, x, y, x2, y2) 35 | 36 | def orientation(self, image, geometry, options): 37 | """ 38 | Wrapper for ``_orientation`` 39 | """ 40 | if options.get('orientation', settings.THUMBNAIL_ORIENTATION): 41 | return self._orientation(image) 42 | self.reoriented = True 43 | return image 44 | 45 | def flip_dimensions(self, image, geometry=None, options=None): 46 | options = options or {} 47 | reoriented = hasattr(self, 'reoriented') 48 | if options.get('orientation', settings.THUMBNAIL_ORIENTATION) and not reoriented: 49 | return self._flip_dimensions(image) 50 | return False 51 | 52 | def colorspace(self, image, geometry, options): 53 | """ 54 | Wrapper for ``_colorspace`` 55 | """ 56 | colorspace = options['colorspace'] 57 | return self._colorspace(image, colorspace) 58 | 59 | def remove_border(self, image, options): 60 | 61 | if options.get('remove_border', False): 62 | x_image, y_image = self.get_image_size(image) 63 | image = self._remove_border(image, x_image, y_image) 64 | 65 | return image 66 | 67 | def _calculate_scaling_factor(self, x_image, y_image, geometry, options): 68 | crop = options['crop'] 69 | factors = (geometry[0] / x_image, geometry[1] / y_image) 70 | return max(factors) if crop else min(factors) 71 | 72 | def scale(self, image, geometry, options): 73 | """ 74 | Wrapper for ``_scale`` 75 | """ 76 | upscale = options['upscale'] 77 | x_image, y_image = map(float, self.get_image_size(image)) 78 | if self.flip_dimensions(image): 79 | x_image, y_image = y_image, x_image 80 | factor = self._calculate_scaling_factor(x_image, y_image, geometry, options) 81 | 82 | if factor < 1 or upscale: 83 | width = toint(x_image * factor) 84 | height = toint(y_image * factor) 85 | image = self._scale(image, width, height) 86 | 87 | return image 88 | 89 | def crop(self, image, geometry, options): 90 | """ 91 | Wrapper for ``_crop`` 92 | """ 93 | crop = options['crop'] 94 | x_image, y_image = self.get_image_size(image) 95 | 96 | if not crop or crop == 'noop': 97 | return image 98 | elif crop == 'smart': 99 | # Smart cropping is suitably different from regular cropping 100 | # to warrant it's own function 101 | return self._entropy_crop(image, geometry[0], geometry[1], x_image, y_image) 102 | 103 | # Handle any other crop option with the backend crop function. 104 | geometry = (min(x_image, geometry[0]), min(y_image, geometry[1])) 105 | x_offset, y_offset = parse_crop(crop, (x_image, y_image), geometry) 106 | return self._crop(image, geometry[0], geometry[1], x_offset, y_offset) 107 | 108 | def rounded(self, image, geometry, options): 109 | """ 110 | Wrapper for ``_rounded`` 111 | """ 112 | r = options['rounded'] 113 | if not r: 114 | return image 115 | return self._rounded(image, int(r)) 116 | 117 | def blur(self, image, geometry, options): 118 | """ 119 | Wrapper for ``_blur`` 120 | """ 121 | radius = options.get('blur') 122 | if radius: 123 | if isinstance(radius, str): 124 | radius = int(radius) 125 | return self._blur(image, radius) 126 | return image 127 | 128 | def padding(self, image, geometry, options): 129 | """ 130 | Wrapper for ``_padding`` 131 | """ 132 | if options.get('padding') and self.get_image_size(image) != geometry: 133 | return self._padding(image, geometry, options) 134 | return image 135 | 136 | def write(self, image, options, thumbnail): 137 | """ 138 | Wrapper for ``_write`` 139 | """ 140 | format_ = options['format'] 141 | quality = options['quality'] 142 | image_info = options.get('image_info', {}) 143 | # additional non-default-value options: 144 | progressive = options.get('progressive', settings.THUMBNAIL_PROGRESSIVE) 145 | raw_data = self._get_raw_data( 146 | image, format_, quality, 147 | image_info=image_info, 148 | progressive=progressive 149 | ) 150 | thumbnail.write(raw_data) 151 | 152 | def cleanup(self, image): 153 | """Some backends need to manually cleanup after thumbnails are created""" 154 | pass 155 | 156 | def get_image_ratio(self, image, options): 157 | """ 158 | Calculates the image ratio. If cropbox option is used, the ratio 159 | may have changed. 160 | """ 161 | cropbox = options['cropbox'] 162 | 163 | if cropbox: 164 | x, y, x2, y2 = parse_cropbox(cropbox) 165 | x = x2 - x 166 | y = y2 - y 167 | else: 168 | x, y = self.get_image_size(image) 169 | 170 | ratio = float(x) / y 171 | 172 | if self.flip_dimensions(image): 173 | ratio = 1.0 / ratio 174 | 175 | return ratio 176 | 177 | def get_image_info(self, image): 178 | """ 179 | Returns metadata of an ImageFile instance 180 | """ 181 | return {} 182 | 183 | # Methods which engines need to implement 184 | # The ``image`` argument refers to a backend image object 185 | def get_image(self, source): 186 | """ 187 | Returns the backend image objects from an ImageFile instance 188 | """ 189 | raise NotImplementedError() 190 | 191 | def get_image_size(self, image): 192 | """ 193 | Returns the image width and height as a tuple 194 | """ 195 | raise NotImplementedError() 196 | 197 | def is_valid_image(self, raw_data): 198 | """ 199 | Checks if the supplied raw data is valid image data 200 | """ 201 | raise NotImplementedError() 202 | 203 | def _orientation(self, image): 204 | """ 205 | Read orientation exif data and orientate the image accordingly 206 | """ 207 | return image 208 | 209 | def _colorspace(self, image, colorspace): 210 | """ 211 | `Valid colorspaces 212 | `_. 213 | Backends need to implement the following:: 214 | 215 | RGB, GRAY 216 | """ 217 | raise NotImplementedError() 218 | 219 | def _remove_border(self, image, image_width, image_height): 220 | """ 221 | Remove borders around images 222 | """ 223 | raise NotImplementedError() 224 | 225 | def _entropy_crop(self, image, geometry_width, geometry_height, image_width, image_height): 226 | """ 227 | Crop the image to the correct aspect ratio 228 | by removing the lowest entropy parts 229 | """ 230 | raise NotImplementedError() 231 | 232 | def _scale(self, image, width, height): 233 | """ 234 | Does the resizing of the image 235 | """ 236 | raise NotImplementedError() 237 | 238 | def _crop(self, image, width, height, x_offset, y_offset): 239 | """ 240 | Crops the image 241 | """ 242 | raise NotImplementedError() 243 | 244 | def _get_raw_data(self, image, format_, quality, image_info=None, progressive=False): 245 | """ 246 | Gets raw data given the image, format and quality. This method is 247 | called from :meth:`write` 248 | """ 249 | raise NotImplementedError() 250 | 251 | def _padding(self, image, geometry, options): 252 | """ 253 | Pads the image 254 | """ 255 | raise NotImplementedError() 256 | 257 | def _cropbox(self, image, x, y, x2, y2): 258 | raise NotImplementedError() 259 | 260 | def _rounded(self, image, r): 261 | raise NotImplementedError() 262 | 263 | def _blur(self, image, radius): 264 | raise NotImplementedError() 265 | -------------------------------------------------------------------------------- /sorl/thumbnail/engines/convert_engine.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import subprocess 5 | from collections import OrderedDict 6 | 7 | from django.core.files.temp import NamedTemporaryFile 8 | from django.utils.encoding import smart_str 9 | 10 | from sorl.thumbnail.base import EXTENSIONS 11 | from sorl.thumbnail.conf import settings 12 | from sorl.thumbnail.engines.base import EngineBase 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | size_re = re.compile(r'^(?:.+) (?:[A-Z0-9]+) (?P\d+)x(?P\d+)') 17 | 18 | 19 | class Engine(EngineBase): 20 | """ 21 | Image object is a dict with source path, options and size 22 | """ 23 | 24 | def write(self, image, options, thumbnail): 25 | """ 26 | Writes the thumbnail image 27 | """ 28 | if options['format'] == 'JPEG' and options.get( 29 | 'progressive', settings.THUMBNAIL_PROGRESSIVE): 30 | image['options']['interlace'] = 'line' 31 | 32 | image['options']['quality'] = options['quality'] 33 | 34 | args = settings.THUMBNAIL_CONVERT.split(' ') 35 | args.append(image['source'] + '[0]') 36 | 37 | for k in image['options']: 38 | v = image['options'][k] 39 | args.append('-%s' % k) 40 | if v is not None: 41 | args.append('%s' % v) 42 | 43 | flatten = "on" 44 | if 'flatten' in options: 45 | flatten = options['flatten'] 46 | 47 | if settings.THUMBNAIL_FLATTEN and not flatten == "off": 48 | args.append('-flatten') 49 | 50 | suffix = '.%s' % EXTENSIONS[options['format']] 51 | 52 | with NamedTemporaryFile(suffix=suffix, mode='rb') as fp: 53 | args.append(fp.name) 54 | args = list(map(smart_str, args)) 55 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 56 | returncode = p.wait() 57 | out, err = p.communicate() 58 | 59 | if returncode: 60 | raise EngineError( 61 | "The command '%s' exited with a non-zero exit code " 62 | "and printed this to stderr:\n%s" 63 | % (" ".join(args), err) 64 | ) 65 | elif err: 66 | logger.error("Captured stderr: %s", err) 67 | 68 | thumbnail.write(fp.read()) 69 | 70 | def cleanup(self, image): 71 | os.remove(image['source']) # we should not need this now 72 | 73 | def get_image(self, source): 74 | """ 75 | Returns the backend image objects from a ImageFile instance 76 | """ 77 | 78 | _, suffix = os.path.splitext(source.name) 79 | 80 | with NamedTemporaryFile(mode='wb', delete=False, suffix=suffix) as fp: 81 | fp.write(source.read()) 82 | return {'source': fp.name, 'options': OrderedDict(), 'size': None} 83 | 84 | def get_image_size(self, image): 85 | """ 86 | Returns the image width and height as a tuple 87 | """ 88 | if image['size'] is None: 89 | args = settings.THUMBNAIL_IDENTIFY.split(' ') 90 | args.append(image['source'] + '[0]') 91 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 92 | p.wait() 93 | m = size_re.match(str(p.stdout.read())) 94 | image['size'] = int(m.group('x')), int(m.group('y')) 95 | return image['size'] 96 | 97 | def is_valid_image(self, raw_data): 98 | """ 99 | This is not very good for imagemagick because it will say anything is 100 | valid that it can use as input. 101 | """ 102 | with NamedTemporaryFile(mode='wb') as fp: 103 | fp.write(raw_data) 104 | fp.flush() 105 | args = settings.THUMBNAIL_IDENTIFY.split(' ') 106 | args.append(fp.name + '[0]') 107 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 108 | retcode = p.wait() 109 | return retcode == 0 110 | 111 | def _get_exif_orientation(self, image): 112 | args = settings.THUMBNAIL_IDENTIFY.split() 113 | image_param = f"{image['source']}[0]" 114 | args.extend(["-format", "%[exif:orientation]", image_param]) 115 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 116 | p.wait() 117 | result = p.stdout.read().strip() 118 | try: 119 | return int(result) 120 | except ValueError: 121 | return None 122 | 123 | def _orientation(self, image): 124 | # return image 125 | # XXX need to get the dimensions right after a transpose. 126 | 127 | if settings.THUMBNAIL_CONVERT.endswith('gm convert'): 128 | orientation = self._get_exif_orientation(image) 129 | if orientation: 130 | options = image['options'] 131 | if orientation == 2: 132 | options['flop'] = None 133 | elif orientation == 3: 134 | options['rotate'] = '180' 135 | elif orientation == 4: 136 | options['flip'] = None 137 | elif orientation == 5: 138 | options['rotate'] = '90' 139 | options['flop'] = None 140 | elif orientation == 6: 141 | options['rotate'] = '90' 142 | elif orientation == 7: 143 | options['rotate'] = '-90' 144 | options['flop'] = None 145 | elif orientation == 8: 146 | options['rotate'] = '-90' 147 | else: 148 | # ImageMagick also corrects the orientation exif data for 149 | # destination 150 | image['options']['auto-orient'] = None 151 | return image 152 | 153 | def _flip_dimensions(self, image): 154 | orientation = self._get_exif_orientation(image) 155 | return orientation and orientation in [5, 6, 7, 8] 156 | 157 | def _colorspace(self, image, colorspace): 158 | """ 159 | `Valid colorspaces 160 | `_. 161 | Backends need to implement the following:: 162 | 163 | RGB, GRAY 164 | """ 165 | image['options']['colorspace'] = colorspace 166 | return image 167 | 168 | def _crop(self, image, width, height, x_offset, y_offset): 169 | """ 170 | Crops the image 171 | """ 172 | image['options']['crop'] = '%sx%s+%s+%s' % (width, height, x_offset, y_offset) 173 | image['size'] = (width, height) # update image size 174 | return image 175 | 176 | def _cropbox(self, image, x, y, x2, y2): 177 | """ 178 | Crops the image to a set of x,y coordinates (x,y) is top left, (x2,y2) is bottom left 179 | """ 180 | image['options']['crop'] = '%sx%s+%s+%s' % (x2 - x, y2 - y, x, y) 181 | image['size'] = (x2 - x, y2 - y) # update image size 182 | return image 183 | 184 | def _scale(self, image, width, height): 185 | """ 186 | Does the resizing of the image 187 | """ 188 | image['options']['scale'] = '%sx%s!' % (width, height) 189 | image['size'] = (width, height) # update image size 190 | return image 191 | 192 | def _padding(self, image, geometry, options): 193 | """ 194 | Pads the image 195 | """ 196 | # The order is important. The gravity option should come before extent. 197 | image['options']['background'] = options.get('padding_color') 198 | image['options']['gravity'] = 'center' 199 | image['options']['extent'] = '%sx%s' % (geometry[0], geometry[1]) 200 | return image 201 | 202 | 203 | class EngineError(Exception): 204 | pass 205 | -------------------------------------------------------------------------------- /sorl/thumbnail/engines/pgmagick_engine.py: -------------------------------------------------------------------------------- 1 | from pgmagick import Blob, Geometry, Image, ImageType, InterlaceType, OrientationType 2 | 3 | from sorl.thumbnail.engines.base import EngineBase 4 | 5 | try: 6 | from pgmagick._pgmagick import get_blob_data 7 | except ImportError: 8 | from base64 import b64decode 9 | 10 | def get_blob_data(blob): 11 | return b64decode(blob.base64()) 12 | 13 | 14 | class Engine(EngineBase): 15 | def get_image(self, source): 16 | blob = Blob() 17 | blob.update(source.read()) 18 | return Image(blob) 19 | 20 | def get_image_size(self, image): 21 | geometry = image.size() 22 | return geometry.width(), geometry.height() 23 | 24 | def is_valid_image(self, raw_data): 25 | blob = Blob() 26 | blob.update(raw_data) 27 | im = Image(blob) 28 | return im.isValid() 29 | 30 | def _cropbox(self, image, x, y, x2, y2): 31 | geometry = Geometry(x2 - x, y2 - y, x, y) 32 | image.crop(geometry) 33 | return image 34 | 35 | def _orientation(self, image): 36 | orientation = image.orientation() 37 | if orientation == OrientationType.TopRightOrientation: 38 | image.flop() 39 | elif orientation == OrientationType.BottomRightOrientation: 40 | image.rotate(180) 41 | elif orientation == OrientationType.BottomLeftOrientation: 42 | image.flip() 43 | elif orientation == OrientationType.LeftTopOrientation: 44 | image.rotate(90) 45 | image.flop() 46 | elif orientation == OrientationType.RightTopOrientation: 47 | image.rotate(90) 48 | elif orientation == OrientationType.RightBottomOrientation: 49 | image.rotate(-90) 50 | image.flop() 51 | elif orientation == OrientationType.LeftBottomOrientation: 52 | image.rotate(-90) 53 | image.orientation(OrientationType.TopLeftOrientation) 54 | 55 | return image 56 | 57 | def _flip_dimensions(self, image): 58 | return image.orientation() in [ 59 | OrientationType.LeftTopOrientation, 60 | OrientationType.RightTopOrientation, 61 | OrientationType.RightBottomOrientation, 62 | OrientationType.LeftBottomOrientation, 63 | ] 64 | 65 | def _colorspace(self, image, colorspace): 66 | if colorspace == 'RGB': 67 | image.type(ImageType.TrueColorMatteType) 68 | elif colorspace == 'GRAY': 69 | image.type(ImageType.GrayscaleMatteType) 70 | else: 71 | return image 72 | return image 73 | 74 | def _scale(self, image, width, height): 75 | geometry = Geometry(width, height) 76 | image.scale(geometry) 77 | return image 78 | 79 | def _crop(self, image, width, height, x_offset, y_offset): 80 | geometry = Geometry(width, height, x_offset, y_offset) 81 | image.crop(geometry) 82 | return image 83 | 84 | def _get_raw_data(self, image, format_, quality, image_info=None, progressive=False): 85 | image.magick(format_.encode('utf8')) 86 | image.quality(quality) 87 | if format_ == 'JPEG' and progressive: 88 | image.interlaceType(InterlaceType.LineInterlace) 89 | blob = Blob() 90 | image.write(blob) 91 | return get_blob_data(blob) 92 | -------------------------------------------------------------------------------- /sorl/thumbnail/engines/vipsthumbnail_engine.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | from collections import OrderedDict 5 | 6 | from django.core.files.temp import NamedTemporaryFile 7 | from django.utils.encoding import smart_str 8 | 9 | from sorl.thumbnail.base import EXTENSIONS 10 | from sorl.thumbnail.conf import settings 11 | from sorl.thumbnail.engines.base import EngineBase 12 | 13 | size_re = re.compile(r'^(?:.+) (?P\d+)x(?P\d+)') 14 | 15 | 16 | class Engine(EngineBase): 17 | """ 18 | Image object is a dict with source path, options and size 19 | """ 20 | 21 | def write(self, image, options, thumbnail): 22 | """ 23 | Writes the thumbnail image 24 | """ 25 | 26 | args = settings.THUMBNAIL_VIPSTHUMBNAIL.split(' ') 27 | args.append(image['source']) 28 | 29 | for k in image['options']: 30 | v = image['options'][k] 31 | args.append('--%s' % k) 32 | if v is not None: 33 | args.append('%s' % v) 34 | 35 | suffix = '.%s' % EXTENSIONS[options['format']] 36 | 37 | write_options = [] 38 | if options['format'] == 'JPEG' and options.get( 39 | 'progressive', settings.THUMBNAIL_PROGRESSIVE): 40 | write_options.append("interlace") 41 | 42 | if options['quality']: 43 | if options['format'] == 'JPEG': 44 | write_options.append("Q=%d" % options['quality']) 45 | 46 | with NamedTemporaryFile(suffix=suffix, mode='rb') as fp: 47 | # older vipsthumbails used -o, this was renamed to -f in 8.0, use 48 | # -o here for commpatibility 49 | args.append("-o") 50 | args.append(fp.name + "[%s]" % ",".join(write_options)) 51 | 52 | args = map(smart_str, args) 53 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 54 | p.wait() 55 | out, err = p.communicate() 56 | 57 | if err: 58 | raise Exception(err) 59 | 60 | thumbnail.write(fp.read()) 61 | 62 | def cleanup(self, image): 63 | os.remove(image['source']) # we should not need this now 64 | 65 | def get_image(self, source): 66 | """ 67 | Returns the backend image objects from a ImageFile instance 68 | """ 69 | with NamedTemporaryFile(mode='wb', delete=False) as fp: 70 | fp.write(source.read()) 71 | return {'source': fp.name, 'options': OrderedDict(), 'size': None} 72 | 73 | def get_image_size(self, image): 74 | """ 75 | Returns the image width and height as a tuple 76 | """ 77 | if image['size'] is None: 78 | args = settings.THUMBNAIL_VIPSHEADER.split(' ') 79 | args.append(image['source']) 80 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 81 | p.wait() 82 | m = size_re.match(str(p.stdout.read())) 83 | image['size'] = int(m.group('x')), int(m.group('y')) 84 | return image['size'] 85 | 86 | def is_valid_image(self, raw_data): 87 | """ 88 | vipsheader will try a lot of formats, including all those supported by 89 | imagemagick if compiled with magick support, this can take a while 90 | """ 91 | with NamedTemporaryFile(mode='wb') as fp: 92 | fp.write(raw_data) 93 | fp.flush() 94 | args = settings.THUMBNAIL_VIPSHEADER.split(' ') 95 | args.append(fp.name) 96 | p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 97 | retcode = p.wait() 98 | return retcode == 0 99 | 100 | def _orientation(self, image): 101 | # vipsthumbnail also corrects the orientation exif data for 102 | # destination 103 | image['options']['rotate'] = None 104 | 105 | return image 106 | 107 | def _colorspace(self, image, colorspace): 108 | """ 109 | vipsthumbnail does not support greyscaling of images, but pretend it 110 | does 111 | """ 112 | return image 113 | 114 | def _scale(self, image, width, height): 115 | """ 116 | Does the resizing of the image 117 | """ 118 | image['options']['size'] = '%sx%s' % (width, height) 119 | image['size'] = (width, height) # update image size 120 | return image 121 | -------------------------------------------------------------------------------- /sorl/thumbnail/engines/wand_engine.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Wand (>=v0.3.0) engine for Sorl-thumbnail 3 | ''' 4 | 5 | from wand import exceptions 6 | from wand.image import Image 7 | from wand.version import MAGICK_VERSION_NUMBER 8 | 9 | from sorl.thumbnail.engines.base import EngineBase 10 | 11 | 12 | class Engine(EngineBase): 13 | def get_image(self, source): 14 | return Image(blob=source.read()) 15 | 16 | def get_image_size(self, image): 17 | return image.size 18 | 19 | def is_valid_image(self, raw_data): 20 | ''' 21 | Wand library makes sure when opening any image that is fine, when 22 | the image is corrupted raises an exception. 23 | ''' 24 | 25 | try: 26 | Image(blob=raw_data) 27 | return True 28 | except (exceptions.CorruptImageError, exceptions.MissingDelegateError): 29 | return False 30 | 31 | def _orientation(self, image): 32 | orientation = image.orientation 33 | if orientation == 'top_right': 34 | image.flop() 35 | elif orientation == 'bottom_right': 36 | image.rotate(degree=180) 37 | elif orientation == 'bottom_left': 38 | image.flip() 39 | elif orientation == 'left_top': 40 | image.rotate(degree=90) 41 | image.flop() 42 | elif orientation == 'right_top': 43 | image.rotate(degree=90) 44 | elif orientation == 'right_bottom': 45 | image.rotate(degree=-90) 46 | image.flop() 47 | elif orientation == 'left_bottom': 48 | image.rotate(degree=-90) 49 | image.orientation = 'top_left' 50 | return image 51 | 52 | def _flip_dimensions(self, image): 53 | return image.orientation in ['left_top', 'right_top', 'right_bottom', 'left_bottom'] 54 | 55 | def _colorspace(self, image, colorspace): 56 | if colorspace == 'RGB': 57 | if image.alpha_channel: 58 | if MAGICK_VERSION_NUMBER < 0x700: 59 | image.type = 'truecolormatte' 60 | else: 61 | image.type = 'truecoloralpha' 62 | else: 63 | image.type = 'truecolor' 64 | elif colorspace == 'GRAY': 65 | if image.alpha_channel: 66 | if MAGICK_VERSION_NUMBER < 0x700: 67 | image.type = 'grayscalematte' 68 | else: 69 | image.type = 'grayscalealpha' 70 | else: 71 | image.type = 'grayscale' 72 | else: 73 | return image 74 | return image 75 | 76 | def _scale(self, image, width, height): 77 | image.resize(width, height) 78 | return image 79 | 80 | def _crop(self, image, width, height, x_offset, y_offset): 81 | image.crop(left=x_offset, top=y_offset, width=width, height=height) 82 | return image 83 | 84 | def _cropbox(self, image, x, y, x2, y2): 85 | image.crop(left=x, top=y, width=x2 - x, height=y2 - y) 86 | return image 87 | 88 | def _get_raw_data(self, image, format_, quality, image_info=None, progressive=False): 89 | image.compression_quality = quality 90 | if format_ == 'JPEG' and progressive: 91 | image.format = 'pjpeg' 92 | else: 93 | image.format = format_ 94 | return image.make_blob() 95 | -------------------------------------------------------------------------------- /sorl/thumbnail/fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.db import models 3 | from django.db.models import Q 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from sorl.thumbnail import default 7 | 8 | __all__ = ('ImageField', 'ImageFormField') 9 | 10 | 11 | class ImageField(models.ImageField): 12 | def delete_file(self, instance, sender, **kwargs): 13 | """ 14 | Adds deletion of thumbnails and key value store references to the 15 | parent class implementation. Only called in Django < 1.2.5 16 | """ 17 | file_ = getattr(instance, self.attname) 18 | 19 | # If no other object of this type references the file, and it's not the 20 | # default value for future objects, delete it from the backend. 21 | query = Q(**{self.name: file_.name}) & ~Q(pk=instance.pk) 22 | qs = sender._default_manager.filter(query) 23 | 24 | if (file_ and file_.name != self.default and not qs): 25 | default.backend.delete(file_) 26 | elif file_: 27 | # Otherwise, just close the file, so it doesn't tie up resources. 28 | file_.close() 29 | 30 | def formfield(self, **kwargs): 31 | defaults = {'form_class': ImageFormField} 32 | defaults.update(kwargs) 33 | return super().formfield(**defaults) 34 | 35 | def save_form_data(self, instance, data): 36 | if data is not None: 37 | setattr(instance, self.name, data or '') 38 | 39 | 40 | class ImageFormField(forms.FileField): 41 | default_error_messages = { 42 | 'invalid_image': _("Upload a valid image. The file you uploaded was " 43 | "either not an image or a corrupted image."), 44 | } 45 | 46 | def to_python(self, data): 47 | """ 48 | Checks that the file-upload field data contains a valid image (GIF, 49 | JPG, PNG, possibly others -- whatever the engine supports). 50 | """ 51 | f = super().to_python(data) 52 | if f is None: 53 | return None 54 | 55 | # We need to get a file raw data to validate it. 56 | if hasattr(data, 'temporary_file_path'): 57 | with open(data.temporary_file_path(), 'rb') as fp: 58 | raw_data = fp.read() 59 | elif hasattr(data, 'read'): 60 | raw_data = data.read() 61 | else: 62 | raw_data = data['content'] 63 | 64 | if not default.engine.is_valid_image(raw_data): 65 | raise forms.ValidationError(self.default_error_messages['invalid_image']) 66 | if hasattr(f, 'seek') and callable(f.seek): 67 | f.seek(0) 68 | 69 | return f 70 | -------------------------------------------------------------------------------- /sorl/thumbnail/helpers.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import math 4 | from importlib import import_module 5 | 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.utils.encoding import force_str 8 | 9 | 10 | class ThumbnailError(Exception): 11 | pass 12 | 13 | 14 | class SortedJSONEncoder(json.JSONEncoder): 15 | """ 16 | A json encoder that sorts the dict keys 17 | """ 18 | 19 | def __init__(self, **kwargs): 20 | kwargs['sort_keys'] = True 21 | super().__init__(**kwargs) 22 | 23 | 24 | def toint(number): 25 | """ 26 | Helper to return rounded int for a float or just the int it self. 27 | """ 28 | if isinstance(number, float): 29 | if number > 1: 30 | number = round(number, 0) 31 | else: 32 | # The following solves when image has small dimensions (like 1x54) 33 | # then scale factor 1 * 0.296296 and `number` will store `0` 34 | # that will later raise ZeroDivisionError. 35 | number = round(math.ceil(number), 0) 36 | return int(number) 37 | 38 | 39 | def tokey(*args): 40 | """ 41 | Computes a unique key from arguments given. 42 | """ 43 | salt = '||'.join([force_str(arg) for arg in args]) 44 | return hashlib.md5(salt.encode()).hexdigest() 45 | 46 | 47 | def serialize(obj): 48 | return json.dumps(obj, cls=SortedJSONEncoder) 49 | 50 | 51 | def deserialize(s): 52 | if isinstance(s, bytes): 53 | return json.loads(s.decode('utf-8')) 54 | return json.loads(s) 55 | 56 | 57 | def get_module_class(class_path): 58 | """ 59 | imports and returns module class from ``path.to.module.Class`` 60 | argument 61 | """ 62 | mod_name, cls_name = class_path.rsplit('.', 1) 63 | 64 | try: 65 | mod = import_module(mod_name) 66 | except ImportError as e: 67 | raise ImproperlyConfigured(('Error importing module %s: "%s"' % (mod_name, e))) 68 | 69 | return getattr(mod, cls_name) 70 | -------------------------------------------------------------------------------- /sorl/thumbnail/images.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import platform 4 | import re 5 | from functools import lru_cache 6 | from urllib.error import URLError 7 | from urllib.parse import quote, quote_plus, urlsplit, urlunsplit 8 | from urllib.request import Request, urlopen 9 | 10 | from django.core.files.base import ContentFile, File 11 | from django.core.files.storage import Storage 12 | from django.utils.encoding import force_str 13 | from django.utils.functional import LazyObject, empty 14 | 15 | from sorl.thumbnail import default 16 | from sorl.thumbnail.conf import settings 17 | from sorl.thumbnail.default import storage as default_storage 18 | from sorl.thumbnail.helpers import ThumbnailError, deserialize, get_module_class, tokey 19 | from sorl.thumbnail.parsers import parse_geometry 20 | 21 | url_pat = re.compile(r'^(https?|ftp):\/\/') 22 | 23 | 24 | @lru_cache 25 | def get_or_create_storage(storage): 26 | return get_module_class(storage)() 27 | 28 | 29 | def serialize_image_file(image_file): 30 | if image_file.size is None: 31 | raise ThumbnailError('Trying to serialize an ``ImageFile`` with a ' 32 | '``None`` size.') 33 | data = { 34 | 'name': image_file.name, 35 | 'storage': image_file.serialize_storage(), 36 | 'size': image_file.size, 37 | } 38 | return json.dumps(data) 39 | 40 | 41 | def deserialize_image_file(s): 42 | data = deserialize(s) 43 | image_file = ImageFile(data['name'], get_or_create_storage(data['storage'])) 44 | image_file.set_size(data['size']) 45 | return image_file 46 | 47 | 48 | class BaseImageFile: 49 | size = [] 50 | 51 | def exists(self): 52 | raise NotImplementedError() 53 | 54 | @property 55 | def width(self): 56 | return self.size[0] 57 | 58 | x = width 59 | 60 | @property 61 | def height(self): 62 | return self.size[1] 63 | 64 | y = height 65 | 66 | def is_portrait(self): 67 | return self.y > self.x 68 | 69 | @property 70 | def ratio(self): 71 | return float(self.x) / float(self.y) 72 | 73 | @property 74 | def url(self): 75 | raise NotImplementedError() 76 | 77 | src = url 78 | 79 | 80 | class ImageFile(BaseImageFile): 81 | _size = None 82 | 83 | def __init__(self, file_, storage=None): 84 | if not file_: 85 | raise ThumbnailError('File is empty.') 86 | 87 | # figure out name 88 | if hasattr(file_, 'name'): 89 | self.name = file_.name 90 | else: 91 | self.name = force_str(file_) 92 | 93 | # TODO: Add a customizable naming method as a signal 94 | 95 | # Remove query args from names. Fixes cache and signature arguments 96 | # from third party services, like Amazon S3 and signature args. 97 | if settings.THUMBNAIL_REMOVE_URL_ARGS: 98 | self.name = self.name.split('?')[0] 99 | 100 | # Support for relative protocol urls 101 | if self.name.startswith('//'): 102 | self.name = 'http:' + self.name 103 | 104 | # figure out storage 105 | if storage is not None: 106 | self.storage = storage 107 | elif hasattr(file_, 'storage'): 108 | self.storage = file_.storage 109 | elif url_pat.match(self.name): 110 | self.storage = UrlStorage() 111 | else: 112 | self.storage = default_storage 113 | 114 | if hasattr(self.storage, 'location'): 115 | location = self.storage.location 116 | if not self.storage.location.endswith("/"): 117 | location += "/" 118 | if self.name.startswith(location): 119 | self.name = self.name[len(location):] 120 | 121 | def __str__(self): 122 | return self.name 123 | 124 | def exists(self): 125 | return self.storage.exists(self.name) 126 | 127 | def set_size(self, size=None): 128 | # set the size if given 129 | if size is not None: 130 | pass 131 | # Don't try to set the size the expensive way if it already has a 132 | # value. 133 | elif self._size is not None: 134 | return 135 | elif hasattr(self.storage, 'image_size'): 136 | # Storage backends can implement ``image_size`` method that 137 | # optimizes this. 138 | size = self.storage.image_size(self.name) 139 | else: 140 | # This is the worst case scenario 141 | image = default.engine.get_image(self) 142 | size = default.engine.get_image_size(image) 143 | if self.flip_dimensions(image): 144 | size = list(size) 145 | size.reverse() 146 | self._size = list(size) 147 | 148 | def flip_dimensions(self, image): 149 | """ 150 | Do not manipulate image, but ask engine whether we'd be doing a 90deg 151 | rotation at some point. 152 | """ 153 | return default.engine.flip_dimensions(image) 154 | 155 | @property 156 | def size(self): 157 | return self._size 158 | 159 | @property 160 | def url(self): 161 | return self.storage.url(self.name) 162 | 163 | def read(self): 164 | f = self.storage.open(self.name) 165 | try: 166 | return f.read() 167 | finally: 168 | f.close() 169 | 170 | def write(self, content): 171 | if not isinstance(content, File): 172 | content = ContentFile(content) 173 | 174 | self._size = None 175 | self.name = self.storage.save(self.name, content) 176 | 177 | return self.name 178 | 179 | def delete(self): 180 | return self.storage.delete(self.name) 181 | 182 | def serialize_storage(self): 183 | if isinstance(self.storage, LazyObject): 184 | # if storage is wrapped in a lazy object we need to get the real 185 | # thing. 186 | if self.storage._wrapped is empty: 187 | self.storage._setup() 188 | cls = self.storage._wrapped.__class__ 189 | else: 190 | cls = self.storage.__class__ 191 | return '%s.%s' % (cls.__module__, cls.__name__) 192 | 193 | @property 194 | def key(self): 195 | return tokey(self.name, self.serialize_storage()) 196 | 197 | def serialize(self): 198 | return serialize_image_file(self) 199 | 200 | 201 | class DummyImageFile(BaseImageFile): 202 | def __init__(self, geometry_string): 203 | self.size = parse_geometry( 204 | geometry_string, 205 | settings.THUMBNAIL_DUMMY_RATIO, 206 | ) 207 | 208 | def exists(self): 209 | return True 210 | 211 | @property 212 | def url(self): 213 | return settings.THUMBNAIL_DUMMY_SOURCE % ( 214 | {'width': self.x, 'height': self.y} 215 | ) 216 | 217 | 218 | class UrlStorage(Storage): 219 | def normalize_url(self, url, charset="utf-8"): 220 | # Convert URL to ASCII before processing 221 | url = url.encode(charset, errors="ignore") 222 | url = url.decode("ascii", errors="ignore") 223 | scheme, netloc, path, qs, anchor = urlsplit(url) 224 | 225 | path = quote(path, b"/%") 226 | qs = quote_plus(qs, b":&%=") 227 | 228 | return urlunsplit((scheme, netloc, path, qs, anchor)) 229 | 230 | def open(self, name, mode='rb'): 231 | url = self.normalize_url(name) 232 | python_version = platform.python_version_tuple()[0] 233 | user_agent = "python-urllib{python_version}/0.6".format(python_version=python_version) 234 | req = Request(url, headers={"User-Agent": user_agent}) 235 | return urlopen(req, timeout=settings.THUMBNAIL_URL_TIMEOUT) 236 | 237 | def exists(self, name): 238 | try: 239 | self.open(name) 240 | except URLError: 241 | return False 242 | return True 243 | 244 | def url(self, name): 245 | return name 246 | 247 | def delete(self, name): 248 | pass 249 | 250 | 251 | def delete_all_thumbnails(): 252 | storage = default.storage 253 | path = settings.THUMBNAIL_PREFIX 254 | 255 | def walk(path): 256 | dirs, files = storage.listdir(path) 257 | for f in files: 258 | storage.delete(os.path.join(path, f)) 259 | for d in dirs: 260 | directory = os.path.join(path, d) 261 | walk(directory) 262 | try: 263 | full_path = storage.path(directory) 264 | except Exception: 265 | continue 266 | os.rmdir(full_path) 267 | 268 | walk(path) 269 | -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/kvstores/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/base.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from sorl.thumbnail.conf import settings 4 | from sorl.thumbnail.helpers import ThumbnailError, deserialize, serialize 5 | from sorl.thumbnail.images import deserialize_image_file, serialize_image_file 6 | 7 | 8 | def add_prefix(key, identity='image'): 9 | """ 10 | Adds prefixes to the key 11 | """ 12 | return '||'.join([settings.THUMBNAIL_KEY_PREFIX, identity, key]) 13 | 14 | 15 | def del_prefix(key): 16 | """ 17 | Removes prefixes from the key 18 | """ 19 | return key.split('||')[-1] 20 | 21 | 22 | class KVStoreBase: 23 | def __init__(self): 24 | if not getattr(self, '_cached_db_kvstore', False): 25 | warnings.warn( 26 | "Using any other KVStore than Cached Db KVStore is deprecated. " 27 | "Please configure the cache that suits your use case at Django " 28 | "level and set this cache alias in THUMBNAIL_CACHE.", 29 | DeprecationWarning, 30 | stacklevel=3, 31 | ) 32 | 33 | def get(self, image_file): 34 | """ 35 | Gets the ``image_file`` from store. Returns ``None`` if not found. 36 | """ 37 | return self._get(image_file.key) 38 | 39 | def set(self, image_file, source=None): 40 | """ 41 | Updates store for the `image_file`. Makes sure the `image_file` has a 42 | size set. 43 | """ 44 | image_file.set_size() # make sure its got a size 45 | self._set(image_file.key, image_file) 46 | if source is not None: 47 | if not self.get(source): 48 | # make sure the source is in kvstore 49 | raise ThumbnailError('Cannot add thumbnails for source: `%s` ' 50 | 'that is not in kvstore.' % source.name) 51 | 52 | # Update the list of thumbnails for source. 53 | thumbnails = self._get(source.key, identity='thumbnails') or [] 54 | thumbnails = set(thumbnails) 55 | thumbnails.add(image_file.key) 56 | 57 | self._set(source.key, list(thumbnails), identity='thumbnails') 58 | 59 | def get_or_set(self, image_file): 60 | cached = self.get(image_file) 61 | if cached is not None: 62 | return cached 63 | self.set(image_file) 64 | return image_file 65 | 66 | def delete(self, image_file, delete_thumbnails=True): 67 | """ 68 | Deletes the reference to the ``image_file`` and deletes the references 69 | to thumbnails as well as thumbnail files if ``delete_thumbnails`` is 70 | `True``. Does not delete the ``image_file`` is self. 71 | """ 72 | if delete_thumbnails: 73 | self.delete_thumbnails(image_file) 74 | self._delete(image_file.key) 75 | 76 | def delete_thumbnails(self, image_file): 77 | """ 78 | Deletes references to thumbnails as well as thumbnail ``image_files``. 79 | """ 80 | thumbnail_keys = self._get(image_file.key, identity='thumbnails') 81 | if thumbnail_keys: 82 | # Delete all thumbnail keys from store and delete the 83 | # thumbnail ImageFiles. 84 | 85 | for key in thumbnail_keys: 86 | thumbnail = self._get(key) 87 | if thumbnail: 88 | self.delete(thumbnail, False) 89 | thumbnail.delete() # delete the actual file 90 | 91 | # Delete the thumbnails key from store 92 | self._delete(image_file.key, identity='thumbnails') 93 | 94 | def delete_all_thumbnail_files(self): 95 | for key in self._find_keys(identity='thumbnails'): 96 | thumbnail_keys = self._get(key, identity='thumbnails') 97 | if thumbnail_keys: 98 | for key in thumbnail_keys: 99 | thumbnail = self._get(key) 100 | if thumbnail: 101 | thumbnail.delete() 102 | 103 | def cleanup(self): 104 | """ 105 | Cleans up the key value store. In detail: 106 | 1. Deletes all key store references for image_files that do not exist 107 | and all key references for its thumbnails *and* their image_files. 108 | 2. Deletes or updates all invalid thumbnail keys 109 | """ 110 | for key in self._find_keys(identity='image'): 111 | image_file = self._get(key) 112 | 113 | if image_file and not image_file.exists(): 114 | self.delete(image_file) 115 | 116 | for key in self._find_keys(identity='thumbnails'): 117 | # We do not need to check for file existence in here since we 118 | # already did that above for all image references 119 | image_file = self._get(key) 120 | 121 | if image_file: 122 | # if there is an image_file then we check all of its thumbnails 123 | # for existence 124 | thumbnail_keys = self._get(key, identity='thumbnails') or [] 125 | thumbnail_keys_set = set(thumbnail_keys) 126 | 127 | for thumbnail_key in thumbnail_keys: 128 | if not self._get(thumbnail_key): 129 | thumbnail_keys_set.remove(thumbnail_key) 130 | 131 | thumbnail_keys = list(thumbnail_keys_set) 132 | 133 | if thumbnail_keys: 134 | self._set(key, thumbnail_keys, identity='thumbnails') 135 | continue 136 | 137 | # if there is no image_file then this thumbnails key is just 138 | # hangin' loose, If the thumbnail_keys ended up empty there is no 139 | # reason for keeping it either 140 | self._delete(key, identity='thumbnails') 141 | 142 | def clear(self): 143 | """ 144 | Brutely clears the key value store for keys with THUMBNAIL_KEY_PREFIX 145 | prefix. Use this in emergency situations. Normally you would probably 146 | want to use the ``cleanup`` method instead. 147 | """ 148 | all_keys = self._find_keys_raw(settings.THUMBNAIL_KEY_PREFIX) 149 | if all_keys: 150 | self._delete_raw(*all_keys) 151 | 152 | def _get(self, key, identity='image'): 153 | """ 154 | Deserializing, prefix wrapper for _get_raw 155 | """ 156 | value = self._get_raw(add_prefix(key, identity)) 157 | 158 | if not value: 159 | return None 160 | 161 | if identity == 'image': 162 | return deserialize_image_file(value) 163 | 164 | return deserialize(value) 165 | 166 | def _set(self, key, value, identity='image'): 167 | """ 168 | Serializing, prefix wrapper for _set_raw 169 | """ 170 | if identity == 'image': 171 | s = serialize_image_file(value) 172 | else: 173 | s = serialize(value) 174 | self._set_raw(add_prefix(key, identity), s) 175 | 176 | def _delete(self, key, identity='image'): 177 | """ 178 | Prefix wrapper for _delete_raw 179 | """ 180 | self._delete_raw(add_prefix(key, identity)) 181 | 182 | def _find_keys(self, identity='image'): 183 | """ 184 | Finds and returns all keys for identity, 185 | """ 186 | prefix = add_prefix('', identity) 187 | raw_keys = self._find_keys_raw(prefix) or [] 188 | for raw_key in raw_keys: 189 | yield del_prefix(raw_key) 190 | 191 | # 192 | # Methods which key-value stores need to implement 193 | # 194 | def _get_raw(self, key): 195 | """ 196 | Gets the value from keystore, returns `None` if not found. 197 | """ 198 | raise NotImplementedError() 199 | 200 | def _set_raw(self, key, value): 201 | """ 202 | Sets value associated to key. Key is expected to be shorter than 200 203 | chars. Value is a ``unicode`` object with an unknown (reasonable) 204 | length. 205 | """ 206 | raise NotImplementedError() 207 | 208 | def _delete_raw(self, *keys): 209 | """ 210 | Deletes the keys. Silent failure for missing keys. 211 | """ 212 | raise NotImplementedError() 213 | 214 | def _find_keys_raw(self, prefix): 215 | """ 216 | Finds all keys with prefix 217 | """ 218 | raise NotImplementedError() 219 | -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/cached_db_kvstore.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import InvalidCacheBackendError, cache, caches 2 | 3 | from sorl.thumbnail.conf import settings 4 | from sorl.thumbnail.kvstores.base import KVStoreBase 5 | from sorl.thumbnail.models import KVStore as KVStoreModel 6 | 7 | 8 | class EMPTY_VALUE: 9 | pass 10 | 11 | 12 | class KVStore(KVStoreBase): 13 | _cached_db_kvstore = True 14 | 15 | @property 16 | def cache(self): 17 | try: 18 | kv_cache = caches[settings.THUMBNAIL_CACHE] 19 | except InvalidCacheBackendError: 20 | kv_cache = cache 21 | return kv_cache 22 | 23 | def clear(self, delete_thumbnails=False): 24 | """ 25 | We can clear the database more efficiently using the prefix here rather 26 | than calling :meth:`_delete_raw`. 27 | """ 28 | prefix = settings.THUMBNAIL_KEY_PREFIX 29 | for key in self._find_keys_raw(prefix): 30 | self.cache.delete(key) 31 | KVStoreModel.objects.filter(key__startswith=prefix).delete() 32 | if delete_thumbnails: 33 | self.delete_all_thumbnail_files() 34 | 35 | def _get_raw(self, key): 36 | value = self.cache.get(key) 37 | if value is None: 38 | try: 39 | value = KVStoreModel.objects.get(key=key).value 40 | except KVStoreModel.DoesNotExist: 41 | # we set the cache to prevent further db lookups 42 | value = EMPTY_VALUE 43 | self.cache.set(key, value, settings.THUMBNAIL_CACHE_TIMEOUT) 44 | if value == EMPTY_VALUE: 45 | return None 46 | return value 47 | 48 | def _set_raw(self, key, value): 49 | kvstore_value, created = KVStoreModel.objects.get_or_create( 50 | key=key, defaults={'value': value}) 51 | if not created: 52 | kvstore_value.value = value 53 | kvstore_value.save() 54 | self.cache.set(key, value, settings.THUMBNAIL_CACHE_TIMEOUT) 55 | 56 | def _delete_raw(self, *keys): 57 | KVStoreModel.objects.filter(key__in=keys).delete() 58 | for key in keys: 59 | self.cache.delete(key) 60 | 61 | def _find_keys_raw(self, prefix): 62 | qs = KVStoreModel.objects.filter(key__startswith=prefix) 63 | return qs.values_list('key', flat=True) 64 | -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/dbm_kvstore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sorl.thumbnail.conf import settings 4 | from sorl.thumbnail.kvstores.base import KVStoreBase 5 | 6 | try: 7 | import anydbm as dbm 8 | except KeyError: 9 | import dbm 10 | except ImportError: 11 | # Python 3, hopefully 12 | import dbm 13 | 14 | # 15 | # OS filesystem locking primitives. TODO: Test Windows versions 16 | # 17 | if os.name == 'nt': 18 | import msvcrt 19 | 20 | def lock(f, readonly): 21 | msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1) 22 | 23 | def unlock(f): 24 | msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1) 25 | else: 26 | import fcntl 27 | 28 | def lock(f, readonly): 29 | fcntl.lockf(f.fileno(), fcntl.LOCK_SH if readonly else fcntl.LOCK_EX) 30 | 31 | def unlock(f): 32 | fcntl.lockf(f.fileno(), fcntl.LOCK_UN) 33 | 34 | 35 | class DBMContext: 36 | """ 37 | A context manager to access the key-value store in a concurrent-safe manner. 38 | """ 39 | __slots__ = ('filename', 'mode', 'readonly', 'lockfile', 'db') 40 | 41 | def __init__(self, filename, mode, readonly): 42 | self.filename = filename 43 | self.mode = mode 44 | self.readonly = readonly 45 | self.lockfile = open(filename + ".lock", 'w+b') 46 | 47 | def __enter__(self): 48 | lock(self.lockfile, self.readonly) 49 | self.db = dbm.open(self.filename, 'c', self.mode) 50 | return self.db 51 | 52 | def __exit__(self, exval, extype, tb): 53 | self.db.close() 54 | unlock(self.lockfile) 55 | self.lockfile.close() 56 | 57 | 58 | class KVStore(KVStoreBase): 59 | # Please note that all the coding effort is devoted to provide correct 60 | # semantics, not performance. Therefore, use this store only in development 61 | # environments. 62 | 63 | def __init__(self): 64 | super().__init__() 65 | self.filename = settings.THUMBNAIL_DBM_FILE 66 | self.mode = settings.THUMBNAIL_DBM_MODE 67 | 68 | def _cast_key(self, key): 69 | return key if isinstance(key, bytes) else key.encode('utf-8') 70 | 71 | def _get_raw(self, key): 72 | with DBMContext(self.filename, self.mode, True) as db: 73 | try: 74 | return db[self._cast_key(key)] 75 | except KeyError: 76 | return None 77 | 78 | def _set_raw(self, key, value): 79 | with DBMContext(self.filename, self.mode, False) as db: 80 | db[self._cast_key(key)] = value 81 | 82 | def _delete_raw(self, *keys): 83 | with DBMContext(self.filename, self.mode, False) as db: 84 | for key in keys: 85 | try: 86 | del db[self._cast_key(key)] 87 | except KeyError: 88 | pass 89 | 90 | def _find_keys_raw(self, prefix): 91 | with DBMContext(self.filename, self.mode, True) as db: 92 | p = self._cast_key(prefix) 93 | return [k.decode('utf-8') for k in db.keys() if k.startswith(p)] 94 | -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/dynamodb_kvstore.py: -------------------------------------------------------------------------------- 1 | import boto 2 | from boto.dynamodb2.table import Table 3 | 4 | from sorl.thumbnail.conf import settings 5 | from sorl.thumbnail.kvstores.base import KVStoreBase 6 | 7 | 8 | class KVStore(KVStoreBase): 9 | def __init__(self): 10 | super().__init__() 11 | region = settings.AWS_REGION_NAME 12 | access_key = settings.AWS_ACCESS_KEY_ID 13 | secret = settings.AWS_SECRET_ACCESS_KEY 14 | conn = boto.dynamodb2.connect_to_region(region, aws_access_key_id=access_key, 15 | aws_secret_access_key=secret) 16 | self.table = Table(settings.THUMBNAIL_DYNAMODB_NAME, connection=conn) 17 | 18 | def _get_raw(self, key): 19 | try: 20 | return self.table.get_item(key=key)['value'] 21 | except boto.dynamodb2.exceptions.ItemNotFound: 22 | pass 23 | 24 | def _set_raw(self, key, value): 25 | try: 26 | item = self.table.get_item(key=key) 27 | except boto.dynamodb2.exceptions.ItemNotFound: 28 | item = self.table.new_item() 29 | item['key'] = key 30 | item['value'] = value 31 | item.save(overwrite=True) 32 | 33 | def _delete_raw(self, *keys): 34 | [self.table.delete_item(key=k) for k in keys] 35 | 36 | def _find_keys_raw(self, prefix): 37 | return [i['key'] for i in self.table.scan(key__beginswith=prefix)] 38 | -------------------------------------------------------------------------------- /sorl/thumbnail/kvstores/redis_kvstore.py: -------------------------------------------------------------------------------- 1 | import redis 2 | 3 | from sorl.thumbnail.conf import settings 4 | from sorl.thumbnail.kvstores.base import KVStoreBase 5 | 6 | 7 | class KVStore(KVStoreBase): 8 | def __init__(self): 9 | super().__init__() 10 | 11 | if hasattr(settings, 'THUMBNAIL_REDIS_URL'): 12 | self.connection = redis.from_url(settings.THUMBNAIL_REDIS_URL) 13 | else: 14 | self.connection = redis.Redis( 15 | host=settings.THUMBNAIL_REDIS_HOST, 16 | port=settings.THUMBNAIL_REDIS_PORT, 17 | db=settings.THUMBNAIL_REDIS_DB, 18 | ssl=settings.THUMBNAIL_REDIS_SSL, 19 | password=settings.THUMBNAIL_REDIS_PASSWORD, 20 | unix_socket_path=settings.THUMBNAIL_REDIS_UNIX_SOCKET_PATH, 21 | ) 22 | 23 | def _get_raw(self, key): 24 | return self.connection.get(key) 25 | 26 | def _set_raw(self, key, value): 27 | return self.connection.set( 28 | key, value, ex=settings.THUMBNAIL_REDIS_TIMEOUT) 29 | 30 | def _delete_raw(self, *keys): 31 | return self.connection.delete(*keys) 32 | 33 | def _find_keys_raw(self, prefix): 34 | pattern = prefix + '*' 35 | return list(map(lambda key: key.decode('utf-8'), 36 | self.connection.keys(pattern=pattern))) 37 | -------------------------------------------------------------------------------- /sorl/thumbnail/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.mail.message import EmailMessage 4 | 5 | from sorl.thumbnail.conf import settings 6 | 7 | 8 | class ThumbnailLogHandler(logging.Handler): 9 | """ 10 | An exception log handler for thumbnail errors. 11 | """ 12 | 13 | def emit(self, record): 14 | import traceback 15 | 16 | if not settings.ADMINS: 17 | return 18 | try: 19 | # Hack to try to get request from context 20 | request = record.exc_info[2].tb_frame.f_locals['context']['request'] 21 | request_repr = repr(request) 22 | request_path = request.path 23 | except Exception: 24 | request_repr = "Request unavailable" 25 | request_path = 'Unknown URL' 26 | if record.exc_info: 27 | stack_trace = '\n'.join(traceback.format_exception(*record.exc_info)) 28 | else: 29 | stack_trace = 'No stack trace available' 30 | message = "%s\n\n%s" % (stack_trace, request_repr) 31 | msg = EmailMessage( 32 | '[sorl-thumbnail] %s: %s' % (record.levelname, request_path), 33 | message, 34 | settings.SERVER_EMAIL, 35 | [a[1] for a in settings.ADMINS], 36 | connection=None 37 | ) 38 | msg.send(fail_silently=True) 39 | -------------------------------------------------------------------------------- /sorl/thumbnail/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/management/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/management/commands/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/management/commands/thumbnail.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from sorl.thumbnail import default 4 | from sorl.thumbnail.images import delete_all_thumbnails 5 | 6 | VALID_LABELS = ['cleanup', 'clear', 'clear_delete_referenced', 'clear_delete_all'] 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Handles thumbnails and key-value store" 11 | missing_args_message = "Enter a valid operation: {}".format( 12 | ", ".join(VALID_LABELS) 13 | ) 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument('args', choices=VALID_LABELS, nargs=1) 17 | 18 | def handle(self, *labels, **options): 19 | verbosity = int(options.get('verbosity')) 20 | label = labels[0] 21 | 22 | if label == 'cleanup': 23 | if verbosity >= 1: 24 | self.stdout.write("Cleanup thumbnails", ending=' ... ') 25 | 26 | default.kvstore.cleanup() 27 | 28 | if verbosity >= 1: 29 | self.stdout.write("[Done]") 30 | 31 | return 32 | 33 | if label == 'clear_delete_referenced': 34 | if verbosity >= 1: 35 | self.stdout.write( 36 | "Delete all thumbnail files referenced in Key Value Store", 37 | ending=' ... ' 38 | ) 39 | 40 | default.kvstore.delete_all_thumbnail_files() 41 | 42 | if verbosity >= 1: 43 | self.stdout.write('[Done]') 44 | 45 | if verbosity >= 1: 46 | self.stdout.write("Clear the Key Value Store", ending=' ... ') 47 | 48 | default.kvstore.clear() 49 | 50 | if verbosity >= 1: 51 | self.stdout.write('[Done]') 52 | 53 | if label == 'clear_delete_all': 54 | if verbosity >= 1: 55 | self.stdout.write("Delete all thumbnail files in THUMBNAIL_PREFIX", ending=' ... ') 56 | 57 | delete_all_thumbnails() 58 | 59 | if verbosity >= 1: 60 | self.stdout.write('[Done]') 61 | 62 | def create_parser(self, prog_name, subcommand, **kwargs): 63 | kwargs['epilog'] = ( 64 | "Documentation: https://sorl-thumbnail.readthedocs.io/en/latest/management.html" 65 | ) 66 | return super().create_parser(prog_name, subcommand, **kwargs) 67 | -------------------------------------------------------------------------------- /sorl/thumbnail/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import models, migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ] 8 | 9 | operations = [ 10 | migrations.CreateModel( 11 | name='KVStore', 12 | fields=[ 13 | ('key', models.CharField(serialize=False, 14 | db_column='key', 15 | max_length=200, 16 | primary_key=True)), 17 | ('value', models.TextField()), 18 | ], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /sorl/thumbnail/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/migrations/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from sorl.thumbnail.conf import settings 4 | 5 | 6 | class KVStore(models.Model): 7 | key = models.CharField( 8 | max_length=200, primary_key=True, 9 | db_column=settings.THUMBNAIL_KEY_DBCOLUMN 10 | ) 11 | value = models.TextField() 12 | 13 | def __str__(self): 14 | return self.key 15 | -------------------------------------------------------------------------------- /sorl/thumbnail/parsers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from sorl.thumbnail.helpers import ThumbnailError, toint 4 | 5 | bgpos_pat = re.compile(r'^(?P\d+)(?P%|px)$') 6 | geometry_pat = re.compile(r'^(?P\d+)?(?:x(?P\d+))?$') 7 | 8 | 9 | class ThumbnailParseError(ThumbnailError): 10 | pass 11 | 12 | 13 | def parse_geometry(geometry, ratio=None): 14 | """ 15 | Parses a geometry string syntax and returns a (width, height) tuple 16 | """ 17 | m = geometry_pat.match(geometry) 18 | 19 | def syntax_error(): 20 | return ThumbnailParseError('Geometry does not have the correct ' 21 | 'syntax: %s' % geometry) 22 | 23 | if not m: 24 | raise syntax_error() 25 | x = m.group('x') 26 | y = m.group('y') 27 | if x is None and y is None: 28 | raise syntax_error() 29 | if x is not None: 30 | x = int(x) 31 | if y is not None: 32 | y = int(y) 33 | # calculate x or y proportionally if not set but we need the image ratio 34 | # for this 35 | if ratio is not None: 36 | ratio = float(ratio) 37 | if x is None: 38 | x = toint(y * ratio) 39 | elif y is None: 40 | y = toint(x / ratio) 41 | return x, y 42 | 43 | 44 | def parse_crop(crop, xy_image, xy_window): 45 | """ 46 | Returns x, y offsets for cropping. The window area should fit inside 47 | image but it works out anyway 48 | """ 49 | 50 | x_alias_percent = { 51 | 'left': '0%', 52 | 'center': '50%', 53 | 'right': '100%', 54 | } 55 | y_alias_percent = { 56 | 'top': '0%', 57 | 'center': '50%', 58 | 'bottom': '100%', 59 | } 60 | xy_crop = crop.split(' ') 61 | 62 | if len(xy_crop) == 1: 63 | if crop in x_alias_percent: 64 | x_crop = x_alias_percent[crop] 65 | y_crop = '50%' 66 | elif crop in y_alias_percent: 67 | y_crop = y_alias_percent[crop] 68 | x_crop = '50%' 69 | else: 70 | x_crop, y_crop = crop, crop 71 | elif len(xy_crop) == 2: 72 | x_crop, y_crop = xy_crop 73 | x_crop = x_alias_percent.get(x_crop, x_crop) 74 | y_crop = y_alias_percent.get(y_crop, y_crop) 75 | else: 76 | raise ThumbnailParseError('Unrecognized crop option: %s' % crop) 77 | 78 | def get_offset(crop, epsilon): 79 | m = bgpos_pat.match(crop) 80 | if not m: 81 | raise ThumbnailParseError('Unrecognized crop option: %s' % crop) 82 | value = int(m.group('value')) # we only take ints in the regexp 83 | unit = m.group('unit') 84 | if unit == '%': 85 | value = epsilon * value / 100.0 86 | 87 | # return ∈ [0, epsilon] 88 | return int(max(0, min(value, epsilon))) 89 | 90 | offset_x = get_offset(x_crop, xy_image[0] - xy_window[0]) 91 | offset_y = get_offset(y_crop, xy_image[1] - xy_window[1]) 92 | return offset_x, offset_y 93 | 94 | 95 | def parse_cropbox(cropbox): 96 | """ 97 | Returns x, y, x2, y2 tuple for cropping. 98 | """ 99 | if isinstance(cropbox, str): 100 | return tuple([int(x.strip()) for x in cropbox.split(',')]) 101 | else: 102 | return tuple(cropbox) 103 | -------------------------------------------------------------------------------- /sorl/thumbnail/shortcuts.py: -------------------------------------------------------------------------------- 1 | from sorl.thumbnail import default 2 | 3 | 4 | def get_thumbnail(file_, geometry_string, **options): 5 | """ 6 | A shortcut for the Backend ``get_thumbnail`` method 7 | """ 8 | return default.backend.get_thumbnail(file_, geometry_string, **options) 9 | 10 | 11 | def delete(file_, delete_file=True): 12 | """ 13 | A shortcut for the Backend ``delete`` method 14 | """ 15 | return default.backend.delete(file_, delete_file) 16 | -------------------------------------------------------------------------------- /sorl/thumbnail/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/sorl/thumbnail/templatetags/__init__.py -------------------------------------------------------------------------------- /sorl/thumbnail/templatetags/sorl_thumbnail.py: -------------------------------------------------------------------------------- 1 | """ 2 | This allows usage of sorl-thumbnail in templates 3 | by {% load sorl_thumbnail %} instead of traditional 4 | {% load thumbnail %}. It's specifically useful in projects 5 | that do make use of multiple thumbnailer libraries (for 6 | instance `easy-thumbnails` alongside `sorl-thumbnail`). 7 | """ 8 | from .thumbnail import * # noqa 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/1_topleft.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/1_topleft.jpg -------------------------------------------------------------------------------- /tests/data/2_topright.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/2_topright.jpg -------------------------------------------------------------------------------- /tests/data/3_bottomright.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/3_bottomright.jpg -------------------------------------------------------------------------------- /tests/data/4_bottomleft.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/4_bottomleft.jpg -------------------------------------------------------------------------------- /tests/data/5_lefttop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/5_lefttop.jpg -------------------------------------------------------------------------------- /tests/data/6_righttop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/6_righttop.jpg -------------------------------------------------------------------------------- /tests/data/7_rightbottom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/7_rightbottom.jpg -------------------------------------------------------------------------------- /tests/data/8_leftbottom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/8_leftbottom.jpg -------------------------------------------------------------------------------- /tests/data/animation_w_transparency.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/animation_w_transparency.gif -------------------------------------------------------------------------------- /tests/data/aspect_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/aspect_test.jpg -------------------------------------------------------------------------------- /tests/data/broken.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/broken.jpeg -------------------------------------------------------------------------------- /tests/data/icc_profile_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/icc_profile_test.jpg -------------------------------------------------------------------------------- /tests/data/white_border.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/data/white_border.jpg -------------------------------------------------------------------------------- /tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/settings/__init__.py -------------------------------------------------------------------------------- /tests/settings/dbm.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_KVSTORE = 'sorl.thumbnail.kvstores.dbm_kvstore.KVStore' 5 | -------------------------------------------------------------------------------- /tests/settings/default.py: -------------------------------------------------------------------------------- 1 | from os.path import join as pjoin, abspath, dirname, pardir 2 | 3 | import django 4 | 5 | SECRET_KEY = 'SECRET' 6 | PROJ_ROOT = abspath(pjoin(dirname(__file__), pardir)) 7 | DATA_ROOT = pjoin(PROJ_ROOT, 'data') 8 | THUMBNAIL_PREFIX = 'test/cache/' 9 | THUMBNAIL_DEBUG = True 10 | THUMBNAIL_LOG_HANDLER = { 11 | 'class': 'sorl.thumbnail.log.ThumbnailLogHandler', 12 | 'level': 'ERROR', 13 | } 14 | THUMBNAIL_KVSTORE = 'tests.thumbnail_tests.kvstore.TestKVStore' 15 | THUMBNAIL_STORAGE = 'default' 16 | STORAGES = { 17 | "default": { 18 | "BACKEND": "tests.thumbnail_tests.storage.TestStorage", 19 | }, 20 | } 21 | 22 | ADMINS = ( 23 | ('Sorl', 'thumbnail@sorl.net'), 24 | ) 25 | DATABASES = { 26 | 'default': { 27 | 'ENGINE': 'django.db.backends.sqlite3', 28 | 'NAME': ':memory:', 29 | } 30 | } 31 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 32 | MEDIA_ROOT = pjoin(PROJ_ROOT, 'media') 33 | MEDIA_URL = '/media/' 34 | ROOT_URLCONF = 'tests.thumbnail_tests.urls' 35 | INSTALLED_APPS = ( 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'sorl.thumbnail', 39 | 'tests.thumbnail_tests', 40 | ) 41 | 42 | TEMPLATES = [{ 43 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 44 | 'APP_DIRS': True, 45 | }] 46 | MIDDLEWARE = ( 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ) 54 | THUMBNAIL_REDIS_SSL = False 55 | -------------------------------------------------------------------------------- /tests/settings/dynamodb.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_KVSTORE = 'sorl.thumbnail.kvstores.dynamodb_kvstore.KVStore' 5 | THUMBNAIL_DYNAMODB_NAME = 'test' 6 | AWS_REGION_NAME = 'eu-central-1' 7 | AWS_ACCESS_KEY_ID = 'use' 8 | AWS_SECRET_ACCESS_KEY = 'yours' 9 | -------------------------------------------------------------------------------- /tests/settings/graphicsmagick.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.convert_engine.Engine' 5 | THUMBNAIL_CONVERT = 'gm convert' 6 | THUMBNAIL_IDENTIFY = 'gm identify' 7 | -------------------------------------------------------------------------------- /tests/settings/imagemagick.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.convert_engine.Engine' 5 | THUMBNAIL_CONVERT = 'convert' 6 | -------------------------------------------------------------------------------- /tests/settings/pgmagick.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.pgmagick_engine.Engine' 5 | -------------------------------------------------------------------------------- /tests/settings/pil.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.pil_engine.Engine' 5 | -------------------------------------------------------------------------------- /tests/settings/redis.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_KVSTORE = 'sorl.thumbnail.kvstores.redis_kvstore.KVStore' 5 | -------------------------------------------------------------------------------- /tests/settings/vipsthumbnail.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.vipsthumbnail_engine.Engine' 5 | -------------------------------------------------------------------------------- /tests/settings/wand.py: -------------------------------------------------------------------------------- 1 | from .default import * 2 | 3 | 4 | THUMBNAIL_ENGINE = 'sorl.thumbnail.engines.wand_engine.Engine' 5 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/sorl-thumbnail/3a1155193f63b3649420abdef6e0314df112f8ba/tests/thumbnail_tests/__init__.py -------------------------------------------------------------------------------- /tests/thumbnail_tests/kvstore.py: -------------------------------------------------------------------------------- 1 | from sorl.thumbnail.kvstores.cached_db_kvstore import KVStore 2 | 3 | 4 | class KVlogHandler: 5 | _log = [] 6 | _active = False 7 | 8 | def start_log(self): 9 | self._active = True 10 | 11 | def stop_log(self): 12 | self._active = False 13 | log = self._log[:] 14 | self._log = [] 15 | return log 16 | 17 | def log(self, s): 18 | if self._active: 19 | self._log.append(s) 20 | 21 | 22 | kvlog = KVlogHandler() 23 | 24 | 25 | class TestKvStoreMixin: 26 | def get(self, *args, **kwargs): 27 | kvlog.log('get') 28 | return super().get(*args, **kwargs) 29 | 30 | def set(self, *args, **kwargs): 31 | kvlog.log('set') 32 | return super().set(*args, **kwargs) 33 | 34 | def delete(self, *args, **kwargs): 35 | kvlog.log('delete') 36 | return super().delete(*args, **kwargs) 37 | 38 | 39 | class TestKVStore(TestKvStoreMixin, KVStore): 40 | pass 41 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from sorl.thumbnail import ImageField 4 | 5 | 6 | class Item(models.Model): 7 | image = ImageField(upload_to=True) 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/storage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.core.files.storage import FileSystemStorage 4 | 5 | 6 | class MockLoggingHandler(logging.Handler): 7 | """Mock logging handler to check for expected logs.""" 8 | 9 | def __init__(self, *args, **kwargs): 10 | self.reset() 11 | super().__init__(*args, **kwargs) 12 | 13 | def emit(self, record): 14 | self.messages[record.levelname.lower()].append(record.getMessage()) 15 | 16 | def reset(self): 17 | self.messages = {'debug': [], 'info': [], 'warning': [], 'error': [], 'critical': []} 18 | 19 | 20 | slog = logging.getLogger('slog') 21 | 22 | 23 | class TestStorageMixin: 24 | def open(self, name, *args, **kwargs): 25 | slog.debug('open: %s' % name) 26 | return super().open(name, *args, **kwargs) 27 | 28 | def save(self, name, *args, **kwargs): 29 | slog.debug('save: %s' % name) 30 | return super().save(name, *args, **kwargs) 31 | 32 | def get_valid_name(self, name, *args, **kwargs): 33 | slog.debug('get_valid_name: %s' % name) 34 | return super().get_valid_name(name, *args, **kwargs) 35 | 36 | def get_available_name(self, name, *args, **kwargs): 37 | slog.debug('get_available_name: %s' % name) 38 | return super().get_available_name(name, *args, **kwargs) 39 | 40 | def path(self, name, *args, **kwargs): 41 | # slog.debug('path: %s' % name) 42 | return super().path(name, *args, **kwargs) 43 | 44 | def delete(self, name, *args, **kwargs): 45 | slog.debug('delete: %s' % name) 46 | return super().delete(name, *args, **kwargs) 47 | 48 | def exists(self, name, *args, **kwargs): 49 | slog.debug('exists: %s' % name) 50 | return super().exists(name, *args, **kwargs) 51 | 52 | def listdir(self, name, *args, **kwargs): 53 | slog.debug('listdir: %s' % name) 54 | return super().listdir(name, *args, **kwargs) 55 | 56 | def size(self, name, *args, **kwargs): 57 | slog.debug('size: %s' % name) 58 | return super().size(name, *args, **kwargs) 59 | 60 | def url(self, name, *args, **kwargs): 61 | # slog.debug('url: %s' % name) 62 | return super().url(name, *args, **kwargs) 63 | 64 | def accessed_time(self, name, *args, **kwargs): 65 | slog.debug('accessed_time: %s' % name) 66 | return super().accessed_time(name, *args, **kwargs) 67 | 68 | def created_time(self, name, *args, **kwargs): 69 | slog.debug('created_time: %s' % name) 70 | return super().created_time(name, *args, **kwargs) 71 | 72 | def modified_time(self, name, *args, **kwargs): 73 | slog.debug('modified_time: %s' % name) 74 | return super().modified_time(name, *args, **kwargs) 75 | 76 | 77 | class TestStorage(TestStorageMixin, FileSystemStorage): 78 | pass 79 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/htmlfilter.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | {% autoescape off %} 3 | {{ text|html_thumbnails }} 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/markdownfilter.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | {{ text|markdown_thumbnails }} 3 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail1.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "200x100" crop="50% 50%" as im %} 3 | 4 | {% empty %} 5 | 6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail1_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "200x100" crop="50% 50%" as im %} 3 | 4 | {% empty %} 5 | 6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail2.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image.name "200x100" format="PNG" quality=99 as im %} 3 | 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail20.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | {% thumbnail image "32x32" crop="center" as im %} 3 | 4 | {% empty %} 5 |

fail

6 | {% endthumbnail %} 7 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail20a.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %} 2 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail2_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image.name "200x100" format="PNG" quality=99 as im %} 3 | 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail3.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail "https://dummyimage.com/100x100/" "20x20" crop="center" as im %} 3 | 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail4.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% if source|is_portrait %} 3 | {% thumbnail source '1x1' as im %} 4 | 5 | {% endthumbnail %} 6 | {% else %} 7 | {% thumbnail source dims as im %} 8 | 9 | {% endthumbnail %} 10 | {% endif %} 11 | {% endspaceless %} 12 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail4a.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% if source|is_portrait %}yes{% else %}no{% endif %} 3 | {% endspaceless %} 4 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail5.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail sorl "30x30" crop="50% 50%" as im %} 3 | 4 | {% empty %} 5 |

empty{{ im }}

6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail6.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" as th %} 3 | {% thumbnail item.image "400x400" as im %} 4 | 5 | {% endthumbnail %} 6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail6_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" as th %} 3 | {% thumbnail item.image "400x400" as im %} 4 | 5 | {% endthumbnail %} 6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail7.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" crop="center" upscale="True" quality=70 as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail7_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" crop="center" upscale="True" quality=70 as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail7a.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" quality=70 crop="center" upscale="True" as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail7a_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" quality=70 crop="center" upscale="True" as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail8.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" options=options as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail8_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" options=options as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail8a.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" crop="center" upscale=True quality=77 as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail8a_alias.html: -------------------------------------------------------------------------------- 1 | {% load sorl_thumbnail %}{% spaceless %} 2 | {% thumbnail item.image "100x100" crop="center" upscale=True quality=77 as th %} 3 | {{ th.url }} 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnail9.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail "invalid_image.jpg" "30x30" crop="50% 50%" as im %} 3 | 4 | {% empty %} 5 |

empty

6 | {% endthumbnail %} 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnaild1.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | 3 | {% thumbnail anything "200x100" as im %} 4 | 5 | {% endthumbnail %} 6 | 7 | {% endspaceless %} 8 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnaild2.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail anything "300" as im %} 3 | 4 | {% endthumbnail %} 5 | {% if not ""|is_portrait %}

NOT

{% endif %} 6 | {% endspaceless %} 7 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnaild3.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail "" "x400" as im %} 3 | 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/templates/thumbnaild4.html: -------------------------------------------------------------------------------- 1 | {% load thumbnail %}{% spaceless %} 2 | {% thumbnail "" "x400" as im %} 3 | 4 | {% endthumbnail %} 5 | {% endspaceless %} 6 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | 3 | from sorl.thumbnail.admin.current import AdminImageWidget 4 | 5 | 6 | class AdminImageWidgetTests(SimpleTestCase): 7 | def test_render_renderer_argument(self): 8 | w = AdminImageWidget() 9 | self.assertHTMLEqual( 10 | w.render('name', 'value', renderer=None), 11 | '' 12 | ) 13 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_alternative_resolutions.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sorl.thumbnail import get_thumbnail 4 | from sorl.thumbnail.conf import settings 5 | from sorl.thumbnail.engines.pil_engine import Engine as PILEngine 6 | from sorl.thumbnail.images import ImageFile 7 | 8 | from .utils import BaseStorageTestCase 9 | 10 | 11 | class AlternativeResolutionsTest(BaseStorageTestCase): 12 | name = 'retina.jpg' 13 | 14 | def setUp(self): 15 | settings.THUMBNAIL_ALTERNATIVE_RESOLUTIONS = [1.5, 2] 16 | super().setUp() 17 | self.maxDiff = None 18 | 19 | def tearDown(self): 20 | super().tearDown() 21 | settings.THUMBNAIL_ALTERNATIVE_RESOLUTIONS = [] 22 | 23 | def test_retina(self): 24 | get_thumbnail(self.image, '50x50') 25 | 26 | actions = [ 27 | 'exists: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d.jpg', 28 | 29 | # save regular resolution, same as in StorageTestCase 30 | 'open: retina.jpg', 31 | 'save: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d.jpg', 32 | 'get_available_name: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d.jpg', 33 | 'exists: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d.jpg', 34 | 35 | # save the 1.5x resolution version 36 | 'save: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@1.5x.jpg', 37 | 'get_available_name: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@1.5x.jpg', 38 | 'exists: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@1.5x.jpg', 39 | 40 | # save the 2x resolution version 41 | 'save: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@2x.jpg', 42 | 'get_available_name: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@2x.jpg', 43 | 'exists: test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@2x.jpg' 44 | ] 45 | self.assertEqual(self.log, actions) 46 | 47 | path = os.path.join(settings.MEDIA_ROOT, 48 | 'test/cache/91/bb/91bb06cf9169e4c52132bb113f2d4c0d@1.5x.jpg') 49 | 50 | with open(path) as fp: 51 | engine = PILEngine() 52 | self.assertEqual(engine.get_image_size(engine.get_image(ImageFile(file_=fp))), (75, 75)) 53 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_backends.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import shutil 4 | import sys 5 | import unittest 6 | from io import StringIO 7 | 8 | from django.test import TestCase 9 | from django.test.utils import override_settings 10 | from PIL import Image 11 | 12 | from sorl.thumbnail import default, delete, get_thumbnail 13 | from sorl.thumbnail.base import ThumbnailBackend 14 | from sorl.thumbnail.conf import settings 15 | from sorl.thumbnail.helpers import get_module_class 16 | from sorl.thumbnail.images import ImageFile 17 | 18 | from .models import Item 19 | from .utils import BaseTestCase, FakeFile, same_open_fd_count 20 | 21 | 22 | class BackendTest(BaseTestCase): 23 | def test_delete(self): 24 | im1 = Item.objects.get(image='100x100.jpg').image 25 | im2 = Item.objects.get(image='500x500.jpg').image 26 | default.kvstore.get_or_set(ImageFile(im1)) 27 | 28 | # exists in kvstore and in storage 29 | self.assertTrue(bool(default.kvstore.get(ImageFile(im1)))) 30 | self.assertTrue(ImageFile(im1).exists()) 31 | 32 | # delete 33 | delete(im1) 34 | self.assertFalse(bool(default.kvstore.get(ImageFile(im1)))) 35 | self.assertFalse(ImageFile(im1).exists()) 36 | 37 | default.kvstore.get_or_set(ImageFile(im2)) 38 | 39 | # exists in kvstore and in storage 40 | self.assertTrue(bool(default.kvstore.get(ImageFile(im2)))) 41 | self.assertTrue(ImageFile(im2).exists()) 42 | 43 | # delete 44 | delete(im2, delete_file=False) 45 | self.assertFalse(bool(default.kvstore.get(ImageFile(im2)))) 46 | self.assertTrue(ImageFile(im2).exists()) 47 | 48 | 49 | @override_settings(THUMBNAIL_PRESERVE_FORMAT=True, THUMBNAIL_FORMAT='XXX') 50 | class PreserveFormatTest(TestCase): 51 | def setUp(self): 52 | self.backend = ThumbnailBackend() 53 | 54 | def test_with_various_formats(self): 55 | self.assertEqual(self.backend._get_format(FakeFile('foo.jpg')), 'JPEG') 56 | self.assertEqual(self.backend._get_format(FakeFile('foo.jpeg')), 'JPEG') 57 | self.assertEqual(self.backend._get_format(FakeFile('foo.png')), 'PNG') 58 | self.assertEqual(self.backend._get_format(FakeFile('foo.gif')), 'GIF') 59 | 60 | def test_double_extension(self): 61 | self.assertEqual(self.backend._get_format(FakeFile('foo.ext.jpg')), 'JPEG') 62 | 63 | def test_that_capitalization_doesnt_matter(self): 64 | self.assertEqual(self.backend._get_format(FakeFile('foo.PNG')), 'PNG') 65 | self.assertEqual(self.backend._get_format(FakeFile('foo.JPG')), 'JPEG') 66 | 67 | def test_fallback_format(self): 68 | self.assertEqual(self.backend._get_format(FakeFile('foo.txt')), 'XXX') 69 | 70 | def test_with_nonascii(self): 71 | self.assertEqual(self.backend._get_format(FakeFile('你好.jpg')), 'JPEG') 72 | 73 | def test_image_remote_url(self): 74 | self.assertEqual(self.backend._get_format(FakeFile('http://example.com/1.png')), 'PNG') 75 | 76 | 77 | @unittest.skipIf(platform.system() == "Windows", "Can't easily count descriptors on windows") 78 | class TestDescriptors(unittest.TestCase): 79 | """Make sure we're not leaving open descriptors on file exceptions""" 80 | ENGINE = None 81 | 82 | def setUp(self): 83 | self.ENGINE = get_module_class(settings.THUMBNAIL_ENGINE)() 84 | 85 | def test_no_source_get_image(self): 86 | """If source image does not exists, properly close all file descriptors""" 87 | source = ImageFile('nonexistent.jpeg') 88 | 89 | with same_open_fd_count(self): 90 | with self.assertRaises(IOError): 91 | self.ENGINE.get_image(source) 92 | 93 | def test_is_valid_image(self): 94 | with same_open_fd_count(self): 95 | self.ENGINE.is_valid_image(b'invalidbinaryimage.jpg') 96 | 97 | @unittest.skipIf('pgmagick_engine' in settings.THUMBNAIL_ENGINE and sys.version_info.major == 2, 98 | 'No output has been received in the last 10 minutes,' 99 | 'this potentially indicates something wrong with the build itself.') 100 | def test_write(self): 101 | with same_open_fd_count(self): 102 | with self.assertRaises(Exception): 103 | self.ENGINE.write(image=self.ENGINE.get_image(StringIO(b'xxx')), 104 | options={'format': 'JPEG', 'quality': 90, 'image_info': {}}, 105 | thumbnail=ImageFile('whatever_thumb.jpg', default.storage)) 106 | 107 | 108 | class ModelTestCase(BaseTestCase): 109 | def test_field1(self): 110 | self.KVSTORE.clear() 111 | item = Item.objects.get(image='100x100.jpg') 112 | im = ImageFile(item.image) 113 | self.assertEqual(None, self.KVSTORE.get(im)) 114 | self.BACKEND.get_thumbnail(im, '27x27') 115 | self.BACKEND.get_thumbnail(im, '81x81') 116 | self.assertNotEqual(None, self.KVSTORE.get(im)) 117 | self.assertEqual(3, len(list(self.KVSTORE._find_keys(identity='image')))) 118 | self.assertEqual(1, len(list(self.KVSTORE._find_keys(identity='thumbnails')))) 119 | 120 | 121 | class TestInputCase(unittest.TestCase): 122 | def setUp(self): 123 | if not os.path.exists(settings.MEDIA_ROOT): 124 | os.makedirs(settings.MEDIA_ROOT) 125 | 126 | self.name = 'åäö.jpg' 127 | 128 | fn = os.path.join(settings.MEDIA_ROOT, self.name) 129 | im = Image.new('L', (666, 666)) 130 | im.save(fn) 131 | 132 | def test_nonascii(self): 133 | # also test the get_thumbnail shortcut 134 | th = get_thumbnail(self.name, '200x200') 135 | self.assertEqual(th.url, '/media/test/cache/f5/26/f52608b56718f62abc45a90ff9459f2c.jpg') 136 | 137 | def tearDown(self): 138 | shutil.rmtree(settings.MEDIA_ROOT) 139 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import StringIO 3 | 4 | from django.core import management 5 | 6 | from sorl.thumbnail.conf import settings 7 | 8 | from .models import Item 9 | from .utils import BaseTestCase 10 | 11 | 12 | class CommandTests(BaseTestCase): 13 | def make_test_thumbnails(self, *sizes): 14 | item = Item.objects.get(image='500x500.jpg') 15 | names = [] 16 | for size in sizes: 17 | th = self.BACKEND.get_thumbnail(item.image, size) 18 | name = os.path.join(settings.MEDIA_ROOT, th.name) 19 | self.assertTrue(os.path.isfile(name)) 20 | names.append(name) 21 | return names 22 | 23 | def test_clear_action(self): 24 | """ Only the KV store is cleared. """ 25 | name1, name2 = self.make_test_thumbnails('400x300', '200x200') 26 | out = StringIO('') 27 | management.call_command('thumbnail', 'clear', verbosity=1, stdout=out) 28 | self.assertEqual(out.getvalue(), "Clear the Key Value Store ... [Done]\n") 29 | self.assertTrue(os.path.isfile(name1)) 30 | self.assertTrue(os.path.isfile(name2)) 31 | 32 | def test_clear_delete_referenced_action(self): 33 | """ Clear KV store and delete referenced thumbnails """ 34 | name1, name2 = self.make_test_thumbnails('400x300', '200x200') 35 | management.call_command('thumbnail', 'clear', verbosity=0) 36 | name3, = self.make_test_thumbnails('100x100') 37 | out = StringIO('') 38 | management.call_command('thumbnail', 'clear_delete_referenced', verbosity=1, stdout=out) 39 | lines = out.getvalue().split("\n") 40 | self.assertEqual(lines[0], 41 | "Delete all thumbnail files referenced in Key Value Store ... [Done]") 42 | self.assertEqual(lines[1], "Clear the Key Value Store ... [Done]") 43 | self.assertTrue(os.path.isfile(name1)) 44 | self.assertTrue(os.path.isfile(name2)) 45 | self.assertFalse(os.path.isfile(name3)) 46 | 47 | def test_clear_delete_all_action(self): 48 | """ Clear KV store and delete all thumbnails """ 49 | name1, name2 = self.make_test_thumbnails('400x300', '200x200') 50 | management.call_command('thumbnail', 'clear', verbosity=0) 51 | name3, = self.make_test_thumbnails('100x100') 52 | out = StringIO('') 53 | management.call_command('thumbnail', 'clear_delete_all', verbosity=1, stdout=out) 54 | lines = out.getvalue().split("\n") 55 | self.assertEqual(lines[0], "Clear the Key Value Store ... [Done]") 56 | self.assertEqual(lines[1], "Delete all thumbnail files in THUMBNAIL_PREFIX ... [Done]") 57 | self.assertFalse(os.path.isfile(name1)) 58 | self.assertFalse(os.path.isfile(name2)) 59 | self.assertFalse(os.path.isfile(name3)) 60 | 61 | def test_cleanup_action(self): 62 | out = StringIO('') 63 | management.call_command('thumbnail', 'cleanup', verbosity=1, stdout=out) 64 | self.assertEqual(out.getvalue(), "Cleanup thumbnails ... [Done]\n") 65 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_filters.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import render_to_string 2 | 3 | from tests.thumbnail_tests.utils import BaseTestCase 4 | 5 | 6 | class FilterTestCase(BaseTestCase): 7 | def test_html_filter(self): 8 | text = 'A image!' 9 | val = render_to_string('htmlfilter.html', {'text': text, }).strip() 10 | 11 | self.assertEqual( 12 | 'A image!', 14 | val 15 | ) 16 | 17 | def test_html_filter_local_url(self): 18 | text = 'A image!' 19 | val = render_to_string('htmlfilter.html', {'text': text, }).strip() 20 | 21 | self.assertEqual( 22 | 'A image!', 24 | val 25 | ) 26 | 27 | def test_markdown_filter(self): 28 | text = '![A image!](https://dummyimage.com/800x800)' 29 | val = render_to_string('markdownfilter.html', {'text': text, }).strip() 30 | 31 | self.assertEqual( 32 | '![A image!](/media/test/cache/91/87/9187bfc1d52b271db9730ee0377547b9.jpg)', 33 | val 34 | ) 35 | 36 | def test_markdown_filter_local_url(self): 37 | text = '![A image!](/media/500x500.jpg)' 38 | val = render_to_string('markdownfilter.html', {'text': text, }).strip() 39 | 40 | self.assertEqual( 41 | '![A image!](/media/test/cache/c7/f2/c7f2880b48e9f07d46a05472c22f0fde.jpg)', 42 | val 43 | ) 44 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_kvstore.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import unittest 3 | 4 | from sorl.thumbnail.kvstores.cached_db_kvstore import KVStore 5 | 6 | 7 | class KVStoreTestCase(unittest.TestCase): 8 | @unittest.skipIf(threading is None, 'Test requires threading') 9 | def test_cache_backend(self): 10 | kv = KVStore() 11 | cache_backends = [] 12 | 13 | def thread_cache_backend(): 14 | cache_backends.append(kv.cache) 15 | 16 | for _ in range(2): 17 | t = threading.Thread(target=thread_cache_backend) 18 | t.start() 19 | t.join() 20 | 21 | # Cache backend for each thread needs to be unique 22 | self.assertNotEqual(cache_backends[0], cache_backends[1]) 23 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_parsers.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from sorl.thumbnail.helpers import ThumbnailError 4 | from sorl.thumbnail.parsers import parse_crop, parse_geometry 5 | 6 | 7 | class CropParserTestCase(unittest.TestCase): 8 | def test_alias_crop(self): 9 | crop = parse_crop('center', (500, 500), (400, 400)) 10 | self.assertEqual(crop, (50, 50)) 11 | crop = parse_crop('right', (500, 500), (400, 400)) 12 | self.assertEqual(crop, (100, 50)) 13 | 14 | def test_percent_crop(self): 15 | crop = parse_crop('50% 0%', (500, 500), (400, 400)) 16 | self.assertEqual(crop, (50, 0)) 17 | crop = parse_crop('10% 80%', (500, 500), (400, 400)) 18 | self.assertEqual(crop, (10, 80)) 19 | 20 | def test_px_crop(self): 21 | crop = parse_crop('200px 33px', (500, 500), (400, 400)) 22 | self.assertEqual(crop, (100, 33)) 23 | 24 | def test_bad_crop(self): 25 | self.assertRaises(ThumbnailError, parse_crop, '-200px', (500, 500), (400, 400)) 26 | 27 | 28 | class GeometryParserTestCase(unittest.TestCase): 29 | def test_geometry(self): 30 | g = parse_geometry('222x30') 31 | self.assertEqual(g, (222, 30)) 32 | g = parse_geometry('222') 33 | self.assertEqual(g, (222, None)) 34 | g = parse_geometry('x999') 35 | self.assertEqual(g, (None, 999)) 36 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.test.utils import override_settings 4 | 5 | from sorl.thumbnail import default, get_thumbnail 6 | from sorl.thumbnail.helpers import get_module_class 7 | 8 | from .utils import BaseStorageTestCase 9 | 10 | 11 | class StorageTestCase(BaseStorageTestCase): 12 | name = 'org.jpg' 13 | 14 | def test_new(self): 15 | get_thumbnail(self.image, '50x50') 16 | actions = [ 17 | 'exists: test/cache/45/bb/45bbbdab11e235a80e603aa119e8786b.jpg', 18 | # open the original for thumbnailing 19 | 'open: org.jpg', 20 | # save the file 21 | 'save: test/cache/45/bb/45bbbdab11e235a80e603aa119e8786b.jpg', 22 | # check for filename 23 | 'get_available_name: test/cache/45/bb/45bbbdab11e235a80e603aa119e8786b.jpg', 24 | # called by get_available_name 25 | 'exists: test/cache/45/bb/45bbbdab11e235a80e603aa119e8786b.jpg', 26 | ] 27 | self.assertEqual(self.log, actions) 28 | 29 | def test_cached(self): 30 | get_thumbnail(self.image, '100x50') 31 | self.log = [] 32 | get_thumbnail(self.image, '100x50') 33 | self.assertEqual(self.log, []) # now this should all be in cache 34 | 35 | def test_safe_methods(self): 36 | im = default.kvstore.get(self.image) 37 | self.assertIsNotNone(im.url) 38 | self.assertIsNotNone(im.x) 39 | self.assertIsNotNone(im.y) 40 | self.assertEqual(self.log, []) 41 | 42 | @override_settings(THUMBNAIL_STORAGE="tests.thumbnail_tests.storage.TestStorage") 43 | def test_storage_setting_as_path_to_class(self): 44 | storage = default.Storage() 45 | self.assertEqual(storage.__class__.__name__, "TestStorage") 46 | 47 | 48 | class UrlStorageTestCase(unittest.TestCase): 49 | def test_encode_utf8_filenames(self): 50 | storage = get_module_class('sorl.thumbnail.images.UrlStorage')() 51 | self.assertEqual( 52 | storage.normalize_url('El jovencito emponzoñado de whisky, qué figura exhibe'), 53 | 'El%20jovencito%20emponzoado%20de%20whisky%2C%20qu%20figura%20exhibe' 54 | ) 55 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from subprocess import PIPE, Popen 4 | 5 | from django.template.loader import render_to_string 6 | from django.test import Client, TestCase 7 | from django.test.utils import override_settings 8 | from PIL import Image 9 | 10 | from sorl.thumbnail.conf import settings 11 | from sorl.thumbnail.engines.pil_engine import Engine as PILEngine 12 | 13 | from .models import Item 14 | from .utils import DATA_DIR, BaseTestCase 15 | 16 | 17 | class TemplateTestCaseA(BaseTestCase): 18 | def test_model(self): 19 | item = Item.objects.get(image='500x500.jpg') 20 | val = render_to_string('thumbnail1.html', {'item': item, }).strip() 21 | self.assertEqual(val, '') 22 | val = render_to_string('thumbnail2.html', {'item': item, }).strip() 23 | self.assertEqual(val, '') 24 | 25 | def test_nested(self): 26 | item = Item.objects.get(image='500x500.jpg') 27 | val = render_to_string('thumbnail6.html', {'item': item, }).strip() 28 | self.assertEqual(val, ( 29 | '' 30 | '' 32 | )) 33 | 34 | def test_serialization_options(self): 35 | item = Item.objects.get(image='500x500.jpg') 36 | 37 | for _ in range(0, 20): 38 | # we could be lucky... 39 | val0 = render_to_string('thumbnail7.html', { 40 | 'item': item, 41 | }).strip() 42 | val1 = render_to_string('thumbnail7a.html', { 43 | 'item': item, 44 | }).strip() 45 | self.assertEqual(val0, val1) 46 | 47 | def test_options(self): 48 | item = Item.objects.get(image='500x500.jpg') 49 | options = { 50 | 'crop': "center", 51 | 'upscale': True, 52 | 'quality': 77, 53 | } 54 | val0 = render_to_string('thumbnail8.html', {'item': item, 'options': options, }).strip() 55 | val1 = render_to_string('thumbnail8a.html', {'item': item, }).strip() 56 | self.assertEqual(val0, val1) 57 | 58 | def test_progressive(self): 59 | im = Item.objects.get(image='500x500.jpg').image 60 | th = self.BACKEND.get_thumbnail(im, '100x100', progressive=True) 61 | path = os.path.join(settings.MEDIA_ROOT, th.name) 62 | p = Popen(['identify', '-verbose', path], stdout=PIPE) 63 | p.wait() 64 | m = re.search('Interlace: JPEG', str(p.stdout.read())) 65 | p.stdout.close() 66 | self.assertEqual(bool(m), True) 67 | 68 | def test_nonprogressive(self): 69 | im = Item.objects.get(image='500x500.jpg').image 70 | th = self.BACKEND.get_thumbnail(im, '100x100', progressive=False) 71 | path = os.path.join(settings.MEDIA_ROOT, th.name) 72 | p = Popen(['identify', '-verbose', path], stdout=PIPE) 73 | p.wait() 74 | m = re.search('Interlace: None', str(p.stdout.read())) 75 | p.stdout.close() 76 | self.assertEqual(bool(m), True) 77 | 78 | def test_orientation(self): 79 | ref = Image.open(os.path.join(DATA_DIR, '1_topleft.jpg')) 80 | top = ref.getpixel((14, 7)) 81 | left = ref.getpixel((7, 14)) 82 | engine = PILEngine() 83 | 84 | def epsilon(x, y): 85 | if isinstance(x, (tuple, list)): 86 | x = sum(x) / len(x) 87 | if isinstance(y, (tuple, list)): 88 | y = sum(y) / len(y) 89 | return abs(x - y) 90 | 91 | data_images = ( 92 | '1_topleft.jpg', 93 | '2_topright.jpg', 94 | '3_bottomright.jpg', 95 | '4_bottomleft.jpg', 96 | '5_lefttop.jpg', 97 | '6_righttop.jpg', 98 | '7_rightbottom.jpg', 99 | '8_leftbottom.jpg' 100 | ) 101 | 102 | for name in data_images: 103 | th = self.BACKEND.get_thumbnail('data/%s' % name, '30x30') 104 | im = engine.get_image(th) 105 | 106 | self.assertLess(epsilon(top, im.getpixel((14, 7))), 10) 107 | self.assertLess(epsilon(left, im.getpixel((7, 14))), 10) 108 | exif = im._getexif() 109 | 110 | # no exif editor in GraphicsMagick 111 | if exif and not ( 112 | settings.THUMBNAIL_CONVERT.endswith('gm convert') 113 | or 'pgmagick_engine' in settings.THUMBNAIL_ENGINE 114 | ): 115 | self.assertEqual(exif.get(0x0112), 1) 116 | 117 | 118 | class TemplateTestCaseB(BaseTestCase): 119 | def test_url(self): 120 | val = render_to_string('thumbnail3.html', {}).strip() 121 | self.assertEqual(val, '') 122 | 123 | def test_portrait(self): 124 | val = render_to_string('thumbnail4.html', { 125 | 'source': 'https://dummyimage.com/120x100/', 126 | 'dims': 'x66', 127 | }).strip() 128 | self.assertEqual(val, 129 | '') 131 | 132 | val = render_to_string('thumbnail4.html', { 133 | 'source': 'https://dummyimage.com/100x120/', 134 | 'dims': 'x66', 135 | }).strip() 136 | self.assertEqual(val, '') 137 | 138 | with override_settings(THUMBNAIL_DEBUG=True): 139 | with self.assertRaises(FileNotFoundError): 140 | render_to_string('thumbnail4a.html', { 141 | 'source': 'broken.jpeg', 142 | }).strip() 143 | 144 | with override_settings(THUMBNAIL_DEBUG=False): 145 | val = render_to_string('thumbnail4a.html', { 146 | 'source': 'broken.jpeg', 147 | }).strip() 148 | self.assertEqual(val, 'no') 149 | 150 | def test_empty(self): 151 | val = render_to_string('thumbnail5.html', {}).strip() 152 | self.assertEqual(val, '

empty

') 153 | 154 | 155 | class TemplateTestCaseClient(TestCase): 156 | def test_empty_error(self): 157 | with override_settings(THUMBNAIL_DEBUG=False): 158 | from django.core.mail import outbox 159 | 160 | client = Client() 161 | response = client.get('/thumbnail9.html') 162 | self.assertEqual(response.content.strip(), b'

empty

') 163 | self.assertEqual(outbox[0].subject, '[sorl-thumbnail] ERROR: Unknown URL') 164 | 165 | end = outbox[0].body.split('\n\n')[-2].split(':')[1].strip() 166 | 167 | self.assertEqual(end, '[Errno 2] No such file or directory') 168 | 169 | 170 | class TemplateTestCaseTemplateTagAlias(BaseTestCase): 171 | """Testing alternative template tag (alias).""" 172 | 173 | def test_model(self): 174 | item = Item.objects.get(image='500x500.jpg') 175 | val = render_to_string( 176 | 'thumbnail1_alias.html', {'item': item} 177 | ).strip() 178 | self.assertEqual( 179 | val, 180 | '' 181 | ) 182 | val = render_to_string( 183 | 'thumbnail2_alias.html', {'item': item} 184 | ).strip() 185 | self.assertEqual( 186 | val, 187 | '' 188 | ) 189 | 190 | def test_nested(self): 191 | item = Item.objects.get(image='500x500.jpg') 192 | val = render_to_string( 193 | 'thumbnail6_alias.html', {'item': item} 194 | ).strip() 195 | self.assertEqual( 196 | val, 197 | ( 198 | '' 200 | '' 203 | ) 204 | ) 205 | 206 | def test_serialization_options(self): 207 | item = Item.objects.get(image='500x500.jpg') 208 | 209 | for _ in range(0, 20): 210 | # we could be lucky... 211 | val0 = render_to_string('thumbnail7_alias.html', { 212 | 'item': item, 213 | }).strip() 214 | val1 = render_to_string('thumbnail7a_alias.html', { 215 | 'item': item, 216 | }).strip() 217 | self.assertEqual(val0, val1) 218 | 219 | def test_options(self): 220 | item = Item.objects.get(image='500x500.jpg') 221 | options = { 222 | 'crop': "center", 223 | 'upscale': True, 224 | 'quality': 77, 225 | } 226 | val0 = render_to_string( 227 | 'thumbnail8_alias.html', 228 | {'item': item, 'options': options} 229 | ).strip() 230 | val1 = render_to_string( 231 | 'thumbnail8a_alias.html', {'item': item} 232 | ).strip() 233 | self.assertEqual(val0, val1) 234 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls import url 3 | except ImportError: 4 | from django.urls import re_path as url 5 | from django.conf import settings 6 | from django.views.static import serve 7 | 8 | from .views import direct_to_template 9 | 10 | urlpatterns = [ 11 | url(r'^media/(?P.+)$', serve, 12 | {'document_root': settings.MEDIA_ROOT, 'show_indexes': True}), 13 | url(r'^(.*\.html)$', direct_to_template), 14 | ] 15 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | import unittest 5 | from contextlib import contextmanager 6 | from subprocess import check_output 7 | 8 | from PIL import Image, ImageDraw 9 | 10 | from sorl.thumbnail.conf import settings 11 | from sorl.thumbnail.helpers import get_module_class 12 | from sorl.thumbnail.images import ImageFile 13 | from sorl.thumbnail.log import ThumbnailLogHandler 14 | 15 | from .models import Item 16 | from .storage import MockLoggingHandler 17 | 18 | DATA_DIR = os.path.join(settings.MEDIA_ROOT, 'data') 19 | 20 | handler = ThumbnailLogHandler() 21 | handler.setLevel(logging.ERROR) 22 | logging.getLogger('sorl.thumbnail').addHandler(handler) 23 | 24 | 25 | @contextmanager 26 | def same_open_fd_count(testcase): 27 | num_opened_fd_before = get_open_fds_count() 28 | yield 29 | num_opened_fd_after = get_open_fds_count() 30 | testcase.assertEqual( 31 | num_opened_fd_before, num_opened_fd_after, 32 | 'Open descriptors count changed, was %s, now %s' % (num_opened_fd_before, 33 | num_opened_fd_after) 34 | ) 35 | 36 | 37 | def get_open_fds_count(): 38 | """Return the number of open file descriptors for current process 39 | 40 | .. warning: will only work on UNIX-like os-es. 41 | """ 42 | pid = os.getpid() 43 | procs = check_output(["lsof", '-w', '-Ff', "-p", str(pid)]) 44 | nprocs = len( 45 | [s for s in procs.decode('utf-8').split('\n') if s and s[0] == 'f' and s[1:].isdigit()] 46 | ) 47 | return nprocs 48 | 49 | 50 | class FakeFile: 51 | """ 52 | Used to test the _get_format method. 53 | """ 54 | 55 | def __init__(self, name): 56 | self.name = name 57 | 58 | 59 | class BaseTestCase(unittest.TestCase): 60 | IMAGE_DIMENSIONS = [(500, 500), (100, 100), (200, 100), ] 61 | BACKEND = None 62 | ENGINE = None 63 | KVSTORE = None 64 | 65 | def create_image(self, name, dim, transparent=False): 66 | """ 67 | Creates an image and prepends the MEDIA ROOT path. 68 | :param name: e.g. 500x500.jpg 69 | :param dim: a dimension tuple e.g. (500, 500) 70 | """ 71 | filename = os.path.join(settings.MEDIA_ROOT, name) 72 | im = Image.new('L', dim) 73 | 74 | if transparent: 75 | draw = ImageDraw.Draw(im) 76 | draw.line((0, 0) + im.size, fill=128) 77 | draw.line((0, im.size[1], im.size[0], 0), fill=128) 78 | 79 | im.save(filename, transparency=0) 80 | else: 81 | im.save(filename) 82 | 83 | return Item.objects.get_or_create(image=name) 84 | 85 | def is_transparent(self, img): 86 | return img.mode in ('RGBA', 'LA') or 'transparency' in img.info 87 | 88 | def setUp(self): 89 | self.BACKEND = get_module_class(settings.THUMBNAIL_BACKEND)() 90 | self.ENGINE = get_module_class(settings.THUMBNAIL_ENGINE)() 91 | self.KVSTORE = get_module_class(settings.THUMBNAIL_KVSTORE)() 92 | 93 | if not os.path.exists(settings.MEDIA_ROOT): 94 | os.makedirs(settings.MEDIA_ROOT) 95 | shutil.copytree(settings.DATA_ROOT, DATA_DIR) 96 | 97 | for dimension in self.IMAGE_DIMENSIONS: 98 | name = '%sx%s.jpg' % dimension 99 | self.create_image(name, dimension) 100 | 101 | def tearDown(self): 102 | shutil.rmtree(settings.MEDIA_ROOT) 103 | 104 | 105 | class BaseStorageTestCase(unittest.TestCase): 106 | image = None 107 | name = None 108 | 109 | def setUp(self): 110 | os.makedirs(settings.MEDIA_ROOT) 111 | filename = os.path.join(settings.MEDIA_ROOT, self.name) 112 | Image.new('L', (100, 100)).save(filename) 113 | self.image = ImageFile(self.name) 114 | 115 | logger = logging.getLogger('slog') 116 | logger.setLevel(logging.DEBUG) 117 | handler = MockLoggingHandler(level=logging.DEBUG) 118 | logger.addHandler(handler) 119 | self.log = handler.messages['debug'] 120 | 121 | def tearDown(self): 122 | shutil.rmtree(settings.MEDIA_ROOT) 123 | -------------------------------------------------------------------------------- /tests/thumbnail_tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.template import loader 3 | 4 | 5 | def direct_to_template(request, template, mimetype=None, **kwargs): 6 | t = loader.get_template(template) 7 | return HttpResponse(t.render({'request': request}), content_type=mimetype) 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.9: py39 4 | 3.10: py310 5 | 3.11: py311 6 | 3.12: py312 7 | 3.13: py313 8 | 9 | [gh-actions:env] 10 | DJANGO = 11 | 4.2: django42 12 | 5.0: django50 13 | 5.1: django51 14 | TARGET = 15 | pil: pil 16 | imagemagick: imagemagick 17 | graphicsmagick: graphicsmagick 18 | redis: redis 19 | wand: wand 20 | dbm: dbm 21 | qa: qa 22 | 23 | [tox] 24 | skipsdist = True 25 | envlist = 26 | py39-qa, 27 | py{39,310,311,312}-django{42}-{pil,imagemagick,graphicsmagick,redis,dynamodb,wand,pgmagick,dbm,vipsthumbnail} 28 | py{310,311,312}-django{50}-{pil,imagemagick,graphicsmagick,redis,dynamodb,wand,pgmagick,dbm,vipsthumbnail} 29 | py{310,311,312,313}-django{51}-{pil,imagemagick,graphicsmagick,redis,dynamodb,wand,pgmagick,dbm,vipsthumbnail} 30 | 31 | [testenv] 32 | deps = 33 | coverage[toml] 34 | pillow 35 | redis: redis 36 | dynamodb: boto 37 | pgmagick: pgmagick 38 | wand: wand 39 | django42: django>=4.2,<4.3 40 | django50: django>=5.0,<5.1 41 | django51: django>=5.1,<5.2 42 | 43 | setenv = 44 | PYTHONPATH = {toxinidir}:{toxinidir} 45 | pil: DJANGO_SETTINGS_MODULE=tests.settings.pil 46 | imagemagick: DJANGO_SETTINGS_MODULE=tests.settings.imagemagick 47 | graphicsmagick: DJANGO_SETTINGS_MODULE=tests.settings.graphicsmagick 48 | vipsthumbnail: DJANGO_SETTINGS_MODULE=tests.settings.vipsthumbnail 49 | redis: DJANGO_SETTINGS_MODULE=tests.settings.redis 50 | dynamodb: DJANGO_SETTINGS_MODULE=tests.settings.dynamodb 51 | wand: DJANGO_SETTINGS_MODULE=tests.settings.wand 52 | pgmagick: DJANGO_SETTINGS_MODULE=tests.settings.pgmagick 53 | dbm: DJANGO_SETTINGS_MODULE=tests.settings.dbm 54 | commands = 55 | coverage run {envbindir}/django-admin test -v2 {posargs:} 56 | coverage report -m 57 | coverage xml 58 | 59 | [testenv:py39-qa] 60 | skip_install = True 61 | deps = 62 | ruff 63 | rstvalidator 64 | commands = 65 | ruff check sorl/ 66 | python -m rstvalidator README.rst CHANGES.rst CONTRIBUTING.rst 67 | -------------------------------------------------------------------------------- /vagrant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | apt-get update 3 | apt-get install -qq git python-software-properties python-pip 4 | apt-get install -qq libjpeg62 libjpeg62-dev zlib1g-dev imagemagick graphicsmagick redis-server 5 | apt-get install -qq libmagickwand-dev libgraphicsmagick++-dev libboost-python-dev libboost-thread-dev 6 | apt-get install -qq libvips-tools 7 | 8 | add-apt-repository -y ppa:deadsnakes/ppa 9 | apt-get update 10 | apt-get install -qq python2.7 python2.7-dev python3.4 python3.4-dev 11 | 12 | pip install tox 13 | 14 | # Fix locale to allow saving unicoded filenames 15 | echo 'LANG=en_US.UTF-8' > /etc/default/locale 16 | 17 | # Start in project dir by default 18 | echo "\n\ncd /vagrant" >> /home/vagrant/.bashrc 19 | --------------------------------------------------------------------------------