├── tests
├── __init__.py
├── images
│ ├── orientation
│ │ ├── README
│ │ ├── landscape_1.jpg
│ │ ├── landscape_2.jpg
│ │ ├── landscape_3.jpg
│ │ ├── landscape_4.jpg
│ │ ├── landscape_5.jpg
│ │ ├── landscape_6.jpg
│ │ ├── landscape_7.jpg
│ │ └── landscape_8.jpg
│ ├── svg
│ │ ├── originals
│ │ │ ├── none
│ │ │ │ ├── circle-crop-125_150_250_300.svg
│ │ │ │ ├── non_zero_origin
│ │ │ │ │ ├── circle-crop-125_150_250_300.svg
│ │ │ │ │ └── circle-crop-original.svg
│ │ │ │ ├── circle-scale-2x-crop-125_150_250_300.svg
│ │ │ │ ├── circle-crop-original.svg
│ │ │ │ └── circle-scale-2x-crop-original.svg
│ │ │ ├── x_max_y_mid_slice
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ ├── scaled-x-crop-original.svg
│ │ │ │ └── scaled-y-crop-original.svg
│ │ │ ├── x_mid_y_max_slice
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ ├── scaled-x-crop-original.svg
│ │ │ │ └── scaled-y-crop-original.svg
│ │ │ ├── x_mid_y_mid_meet
│ │ │ │ ├── ratio-match-crop-0_200_200_400.svg
│ │ │ │ ├── non_zero_origin
│ │ │ │ │ ├── ratio-match-crop-0_200_200_400.svg
│ │ │ │ │ ├── translated-x-no-scale-crop-original.svg
│ │ │ │ │ ├── translated-y-no-scale-crop-original.svg
│ │ │ │ │ ├── negative-translated-y-no-scale-crop-original.svg
│ │ │ │ │ └── ratio-match-crop-original.svg
│ │ │ │ ├── translated-y-no-scale-crop-150_0_450_400.svg
│ │ │ │ ├── translated-y-2x-scale-crop-150_100_450_300.svg
│ │ │ │ ├── translated-y-no-scale-crop-150_100_450_300.svg
│ │ │ │ ├── issue-112-crop-original.svg
│ │ │ │ ├── translated-x-2x-scale-crop-original.svg
│ │ │ │ ├── translated-x-no-scale-crop-original.svg
│ │ │ │ ├── ratio-match-crop-original.svg
│ │ │ │ ├── translated-y-2x-scale-crop-original.svg
│ │ │ │ └── translated-y-no-scale-crop-original.svg
│ │ │ ├── x_mid_y_mid_slice
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ ├── non_zero_origin
│ │ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ │ ├── scaled-y-crop-original.svg
│ │ │ │ │ └── scaled-x-crop-original.svg
│ │ │ │ ├── scaled-x-crop-original.svg
│ │ │ │ └── scaled-y-crop-original.svg
│ │ │ ├── x_mid_y_min_slice
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ ├── scaled-x-crop-original.svg
│ │ │ │ └── scaled-y-crop-original.svg
│ │ │ ├── x_min_y_mid_slice
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ │ ├── scaled-y-crop-original.svg
│ │ │ │ └── scaled-x-crop-original.svg
│ │ │ ├── x_min_y_min_meet
│ │ │ │ ├── scaled-down-crop-0_200_400_250.svg
│ │ │ │ └── scaled-down-crop-original.svg
│ │ │ ├── x_max_y_mid_meet
│ │ │ │ ├── translated-x-no-scale-crop-200_0_600_400.svg
│ │ │ │ └── translated-x-no-scale-crop-original.svg
│ │ │ ├── x_mid_y_min_meet
│ │ │ │ ├── translated-y-no-scale-crop-150_0_450_200.svg
│ │ │ │ └── translated-y-no-scale-crop-original.svg
│ │ │ ├── x_min_y_mid_meet
│ │ │ │ ├── translated-x-no-scale-crop-0_0_400_400.svg
│ │ │ │ └── translated-x-no-scale-crop-original.svg
│ │ │ └── x_mid_y_max_meet
│ │ │ │ ├── translated-y-no-scale-crop-150_200_450_400.svg
│ │ │ │ └── translated-y-no-scale-crop-original.svg
│ │ └── results
│ │ │ ├── x_mid_y_mid_meet
│ │ │ ├── translated-x-2x-scale-crop-original.svg
│ │ │ ├── translated-x-no-scale-crop-original.svg
│ │ │ ├── non_zero_origin
│ │ │ │ ├── translated-x-no-scale-crop-original.svg
│ │ │ │ ├── translated-y-no-scale-crop-original.svg
│ │ │ │ ├── negative-translated-y-no-scale-crop-original.svg
│ │ │ │ ├── ratio-match-crop-0_200_200_400.svg
│ │ │ │ └── ratio-match-crop-original.svg
│ │ │ ├── issue-112-crop-original.svg
│ │ │ ├── ratio-match-crop-original.svg
│ │ │ ├── ratio-match-crop-0_200_200_400.svg
│ │ │ ├── translated-y-2x-scale-crop-150_100_450_300.svg
│ │ │ ├── translated-y-2x-scale-crop-original.svg
│ │ │ ├── translated-y-no-scale-crop-150_100_450_300.svg
│ │ │ ├── translated-y-no-scale-crop-original.svg
│ │ │ └── translated-y-no-scale-crop-150_0_450_400.svg
│ │ │ ├── x_max_y_mid_meet
│ │ │ ├── translated-x-no-scale-crop-200_0_600_400.svg
│ │ │ └── translated-x-no-scale-crop-original.svg
│ │ │ ├── x_min_y_mid_meet
│ │ │ ├── translated-x-no-scale-crop-0_0_400_400.svg
│ │ │ └── translated-x-no-scale-crop-original.svg
│ │ │ ├── none
│ │ │ ├── circle-crop-original.svg
│ │ │ ├── circle-scale-2x-crop-125_150_250_300.svg
│ │ │ ├── circle-scale-2x-crop-original.svg
│ │ │ ├── circle-crop-125_150_250_300.svg
│ │ │ └── non_zero_origin
│ │ │ │ ├── circle-crop-original.svg
│ │ │ │ └── circle-crop-125_150_250_300.svg
│ │ │ ├── x_mid_y_max_meet
│ │ │ ├── translated-y-no-scale-crop-original.svg
│ │ │ └── translated-y-no-scale-crop-150_200_450_400.svg
│ │ │ ├── x_mid_y_min_meet
│ │ │ ├── translated-y-no-scale-crop-150_0_450_200.svg
│ │ │ └── translated-y-no-scale-crop-original.svg
│ │ │ ├── x_mid_y_min_slice
│ │ │ ├── scaled-x-crop-original.svg
│ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ └── scaled-y-crop-original.svg
│ │ │ ├── x_min_y_mid_slice
│ │ │ ├── scaled-y-crop-original.svg
│ │ │ ├── scaled-x-crop-original.svg
│ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ └── scaled-x-crop-200_200_400_400.svg
│ │ │ ├── x_max_y_mid_slice
│ │ │ ├── scaled-x-crop-original.svg
│ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ ├── scaled-y-crop-original.svg
│ │ │ └── scaled-x-crop-200_200_400_400.svg
│ │ │ ├── x_mid_y_max_slice
│ │ │ ├── scaled-x-crop-original.svg
│ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ ├── scaled-y-crop-original.svg
│ │ │ └── scaled-x-crop-200_200_400_400.svg
│ │ │ ├── x_mid_y_mid_slice
│ │ │ ├── scaled-x-crop-original.svg
│ │ │ ├── scaled-y-crop-100_100_300_300.svg
│ │ │ ├── scaled-y-crop-original.svg
│ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ └── non_zero_origin
│ │ │ │ ├── scaled-x-crop-original.svg
│ │ │ │ ├── scaled-y-crop-original.svg
│ │ │ │ ├── scaled-x-crop-200_200_400_400.svg
│ │ │ │ └── scaled-y-crop-100_100_300_300.svg
│ │ │ └── x_min_y_min_meet
│ │ │ ├── scaled-down-crop-0_200_400_250.svg
│ │ │ └── scaled-down-crop-original.svg
│ ├── cmyk.jpg
│ ├── flower.jpg
│ ├── people.jpg
│ ├── sails.bmp
│ ├── tree.avif
│ ├── tree.heic
│ ├── tree.webp
│ ├── wagtail.ico
│ ├── cameraman.tif
│ ├── transparent.gif
│ ├── transparent.png
│ ├── tux_w_alpha.webp
│ ├── newtons_cradle.gif
│ ├── colorchecker_sRGB.jpg
│ ├── optimizers
│ │ ├── original.gif
│ │ ├── original.jpg
│ │ ├── original.png
│ │ ├── optimized.gif
│ │ ├── optimized.jpg
│ │ ├── optimized.png
│ │ ├── optimized.webp
│ │ └── original.webp
│ ├── colorchecker_ECI_RGB_v2.jpg
│ ├── transparent_with_icc_profile.png
│ └── dog_and_lake_cmyk_with_icc_profile.jpg
├── test_opencv.py
├── test_optimizers.py
├── test_svg_coordinate_transforms.py
└── test_image.py
├── docs
├── _static
│ └── .gitkeep
├── .gitignore
├── autobuild.sh
├── spelling_wordlist.txt
├── guide
│ ├── index.rst
│ ├── optimize.rst
│ ├── open.rst
│ ├── save.rst
│ ├── operations.rst
│ └── extend.rst
├── index.rst
├── installation.rst
├── conf.py
├── concepts.rst
├── Makefile
├── changelog.rst
└── reference.rst
├── willow
├── plugins
│ ├── __init__.py
│ ├── opencv.py
│ └── wand.py
├── utils
│ ├── __init__.py
│ └── deprecation.py
├── optimizers
│ ├── __init__.py
│ ├── gifsicle.py
│ ├── optipng.py
│ ├── jpegoptim.py
│ ├── pngquant.py
│ ├── cwebp.py
│ └── base.py
├── __init__.py
├── image.py
└── svg.py
├── CHANGELOG.txt
├── MANIFEST.in
├── .gitignore
├── docker-test.sh
├── .readthedocs.yaml
├── Dockerfile
├── runtests.py
├── .pre-commit-config.yaml
├── .github
└── workflows
│ ├── publish.yml
│ └── test.yml
├── LICENSE
├── pyproject.toml
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/_static/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | /_build
2 |
--------------------------------------------------------------------------------
/willow/plugins/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/willow/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/orientation/README:
--------------------------------------------------------------------------------
1 | Images to test proper reading of orientation tags.
2 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/circle-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 | circle-crop-original.svg
--------------------------------------------------------------------------------
/CHANGELOG.txt:
--------------------------------------------------------------------------------
1 | The changelog has moved to https://willow.wagtail.org/latest/changelog.html
2 |
--------------------------------------------------------------------------------
/tests/images/cmyk.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/cmyk.jpg
--------------------------------------------------------------------------------
/tests/images/flower.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/flower.jpg
--------------------------------------------------------------------------------
/tests/images/people.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/people.jpg
--------------------------------------------------------------------------------
/tests/images/sails.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/sails.bmp
--------------------------------------------------------------------------------
/tests/images/tree.avif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/tree.avif
--------------------------------------------------------------------------------
/tests/images/tree.heic:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/tree.heic
--------------------------------------------------------------------------------
/tests/images/tree.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/tree.webp
--------------------------------------------------------------------------------
/tests/images/wagtail.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/wagtail.ico
--------------------------------------------------------------------------------
/willow/utils/deprecation.py:
--------------------------------------------------------------------------------
1 | class RemovedInWillow17Warning(DeprecationWarning):
2 | pass
3 |
--------------------------------------------------------------------------------
/tests/images/cameraman.tif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/cameraman.tif
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/non_zero_origin/circle-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 | circle-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/circle-scale-2x-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 | circle-scale-2x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/ratio-match-crop-0_200_200_400.svg:
--------------------------------------------------------------------------------
1 | ratio-match-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_min_meet/scaled-down-crop-0_200_400_250.svg:
--------------------------------------------------------------------------------
1 | scaled-down-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/transparent.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/transparent.gif
--------------------------------------------------------------------------------
/tests/images/transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/transparent.png
--------------------------------------------------------------------------------
/tests/images/tux_w_alpha.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/tux_w_alpha.webp
--------------------------------------------------------------------------------
/tests/images/newtons_cradle.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/newtons_cradle.gif
--------------------------------------------------------------------------------
/tests/images/colorchecker_sRGB.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/colorchecker_sRGB.jpg
--------------------------------------------------------------------------------
/tests/images/optimizers/original.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/original.gif
--------------------------------------------------------------------------------
/tests/images/optimizers/original.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/original.jpg
--------------------------------------------------------------------------------
/tests/images/optimizers/original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/original.png
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-0_200_200_400.svg:
--------------------------------------------------------------------------------
1 | ratio-match-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 | scaled-x-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 | scaled-y-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/optimizers/optimized.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/optimized.gif
--------------------------------------------------------------------------------
/tests/images/optimizers/optimized.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/optimized.jpg
--------------------------------------------------------------------------------
/tests/images/optimizers/optimized.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/optimized.png
--------------------------------------------------------------------------------
/tests/images/optimizers/optimized.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/optimized.webp
--------------------------------------------------------------------------------
/tests/images/optimizers/original.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/optimizers/original.webp
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_meet/translated-x-no-scale-crop-200_0_600_400.svg:
--------------------------------------------------------------------------------
1 | translated-x-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-y-no-scale-crop-150_0_450_400.svg:
--------------------------------------------------------------------------------
1 | translated-y-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_meet/translated-y-no-scale-crop-150_0_450_200.svg:
--------------------------------------------------------------------------------
1 | translated-y-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_meet/translated-x-no-scale-crop-0_0_400_400.svg:
--------------------------------------------------------------------------------
1 | translated-x-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | graft willow
2 | global-exclude __pycache__
3 | global-exclude *.py[co]
4 | global-exclude *.swp
5 | include LICENSE
6 |
--------------------------------------------------------------------------------
/tests/images/colorchecker_ECI_RGB_v2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/colorchecker_ECI_RGB_v2.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_1.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_2.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_3.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_4.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_5.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_6.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_7.jpg
--------------------------------------------------------------------------------
/tests/images/orientation/landscape_8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/orientation/landscape_8.jpg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_meet/translated-y-no-scale-crop-150_200_450_400.svg:
--------------------------------------------------------------------------------
1 | translated-y-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-y-2x-scale-crop-150_100_450_300.svg:
--------------------------------------------------------------------------------
1 | translated-y-2x-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-y-no-scale-crop-150_100_450_300.svg:
--------------------------------------------------------------------------------
1 | translated-y-no-scale-crop-original.svg
--------------------------------------------------------------------------------
/tests/images/transparent_with_icc_profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/transparent_with_icc_profile.png
--------------------------------------------------------------------------------
/tests/images/dog_and_lake_cmyk_with_icc_profile.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wagtail/Willow/HEAD/tests/images/dog_and_lake_cmyk_with_icc_profile.jpg
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.swp
3 | /build
4 | /Willow.egg-info
5 | /dist
6 | /venv
7 | .venv
8 | .vscode
9 | .ruff_cache
10 | .coverage*
11 | .DS_Store
12 |
--------------------------------------------------------------------------------
/docs/autobuild.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | echo "Waiting for you to save the docs..."
4 | watchmedo shell-command --patterns="*.rst" --ignore-pattern='_build/*' --recursive --command='make html; echo "Waiting for more changes..."'
5 |
--------------------------------------------------------------------------------
/docker-test.sh:
--------------------------------------------------------------------------------
1 | # Runs the tests using the provided Dockerfile
2 | # Saves having to install OpenCV locally
3 |
4 | docker build -t willow-opencv .
5 | docker run --rm -ti -v $(pwd):/code willow-opencv python runtests.py --opencv
6 |
--------------------------------------------------------------------------------
/willow/optimizers/__init__.py:
--------------------------------------------------------------------------------
1 | from .cwebp import Cwebp # noqa: F401
2 | from .gifsicle import Gifsicle # noqa: F401
3 | from .jpegoptim import Jpegoptim # noqa: F401
4 | from .optipng import Optipng # noqa: F401
5 | from .pngquant import Pngquant # noqa: F401
6 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-x-2x-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_meet/translated-x-no-scale-crop-200_0_600_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_meet/translated-x-no-scale-crop-0_0_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/issue-112-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/circle-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/issue-112-crop-original.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/circle-scale-2x-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/circle-scale-2x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/ratio-match-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | version: 2
3 |
4 | build:
5 | os: ubuntu-24.04
6 | tools:
7 | # Keep in-sync with tox.ini/docs and ci.yml/docs
8 | python: "3.12"
9 |
10 | sphinx:
11 | configuration: docs/conf.py
12 |
13 | python:
14 | install:
15 | - method: pip
16 | path: .
17 | extra_requirements:
18 | - docs
19 |
--------------------------------------------------------------------------------
/docs/spelling_wordlist.txt:
--------------------------------------------------------------------------------
1 | imagemagick
2 | opencv
3 | pillow
4 | numpy
5 | Haar
6 | avif
7 | gif
8 | jpg
9 | jpeg
10 | png
11 | webp
12 | WebP
13 | heif
14 | pre-commit
15 | backend
16 | backends
17 | natively
18 | Changelog
19 | changelog
20 | pre
21 | frmdstryr
22 | kaedroho
23 | mozgsml
24 | mrchrisadams
25 | zerolab
26 | simo
27 | Sigurdur
28 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/circle-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/ratio-match-crop-0_200_200_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/non_zero_origin/circle-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/negative-translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-y-2x-scale-crop-150_100_450_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-y-2x-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-150_100_450_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/none/non_zero_origin/circle-crop-125_150_250_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-150_0_450_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-0_200_200_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # This Dockerfile is used to easily run the OpenCV tests without having to install OpenCV on the host machine.
2 | FROM python:3.13-slim-bookworm
3 | RUN apt update && apt install -y imagemagick
4 | RUN pip install opencv-python-headless
5 |
6 | WORKDIR /code
7 | COPY . ./
8 | RUN pip install -e .[testing]
9 | CMD [ "python", "./runtests.py", "-v", "--opencv" ]
10 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-x-2x-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_meet/translated-y-no-scale-crop-150_0_450_200.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_meet/translated-y-no-scale-crop-150_200_450_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/circle-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/circle-scale-2x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/negative-translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_meet/translated-x-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/none/non_zero_origin/circle-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/ratio-match-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_min_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_slice/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-y-2x-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_max_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_max_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_mid_slice/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_min_meet/scaled-down-crop-0_200_400_250.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_min_y_min_meet/scaled-down-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/docs/guide/index.rst:
--------------------------------------------------------------------------------
1 | ===========
2 | Usage guide
3 | ===========
4 |
5 | If you're looking at Willow for the first time, have a look at the :doc:`concepts
6 | ` and :doc:`installation guide ` first.
7 |
8 | This section describes general, everyday usage of the Willow library.
9 |
10 | Index
11 | =====
12 |
13 | .. toctree::
14 | :maxdepth: 2
15 | :titlesonly:
16 |
17 | open
18 | operations
19 | save
20 | optimize
21 | extend
22 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-200_200_400_400.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-100_100_300_300.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_meet/translated-y-no-scale-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_max_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_max_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_min_slice/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_mid_slice/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_min_y_min_meet/scaled-down-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-original.svg:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/willow/optimizers/gifsicle.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from .base import OptimizerBase
4 |
5 | __all__ = ["Gifsicle"]
6 |
7 |
8 | class Gifsicle(OptimizerBase):
9 | """http://www.lcdf.org/gifsicle/"""
10 |
11 | library_name: ClassVar[str] = "gifsicle"
12 | image_format: ClassVar[str] = "gif"
13 |
14 | @classmethod
15 | def get_command_arguments(cls, file_path: str) -> list[str]:
16 | return [
17 | "-b", # required parameter for the package
18 | "-O3", # slowest, but produces best results
19 | file_path, # the file
20 | ]
21 |
--------------------------------------------------------------------------------
/willow/optimizers/optipng.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from .base import OptimizerBase
4 |
5 | __all__ = ["Optipng"]
6 |
7 |
8 | class Optipng(OptimizerBase):
9 | """https://optipng.sourceforge.net/"""
10 |
11 | library_name: ClassVar[str] = "optipng"
12 | image_format: ClassVar[str] = "png"
13 |
14 | @classmethod
15 | def get_command_arguments(cls, file_path: str) -> list[str]:
16 | return [
17 | "-quiet",
18 | "-o2", # optimization level 2 (out of 7)
19 | "-i0", # non-interlaced, progressive scanned image
20 | file_path, # the file
21 | ]
22 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import sys
4 | import unittest
5 |
6 | from tests.test_image import * # noqa: F403
7 | from tests.test_optimizers import * # noqa: F403
8 | from tests.test_pillow import * # noqa: F403
9 | from tests.test_registry import * # noqa: F403
10 | from tests.test_svg_coordinate_transforms import * # noqa: F403
11 | from tests.test_svg_image import * # noqa: F403
12 | from tests.test_wand import * # noqa: F403
13 |
14 | if __name__ == "__main__":
15 | args = list(sys.argv)
16 |
17 | if "--opencv" in args:
18 | from tests.test_opencv import * # noqa: F403
19 |
20 | args.remove("--opencv")
21 |
22 | unittest.main(argv=args)
23 |
--------------------------------------------------------------------------------
/willow/optimizers/jpegoptim.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from .base import OptimizerBase
4 |
5 | __all__ = ["Jpegoptim"]
6 |
7 |
8 | class Jpegoptim(OptimizerBase):
9 | """https://github.com/tjko/jpegoptim"""
10 |
11 | library_name: ClassVar[str] = "jpegoptim"
12 | image_format: ClassVar[str] = "jpeg"
13 |
14 | @classmethod
15 | def get_command_arguments(cls, file_path: str) -> list[str]:
16 | return [
17 | "--strip-all", # strip out all text information like comments and EXIF data
18 | "--max=85", # set maximum quality
19 | "--all-progressive", # make the resulting image progressive
20 | file_path,
21 | ]
22 |
--------------------------------------------------------------------------------
/willow/optimizers/pngquant.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from .base import OptimizerBase
4 |
5 | __all__ = ["Pngquant"]
6 |
7 |
8 | class Pngquant(OptimizerBase):
9 | """https://pngquant.org/"""
10 |
11 | library_name: ClassVar[str] = "pngquant"
12 | image_format: ClassVar[str] = "png"
13 |
14 | @classmethod
15 | def get_command_arguments(
16 | cls, file_path: str, progressive: bool = False
17 | ) -> list[str]:
18 | return [
19 | "--force", # allow overwriting existing files
20 | "--strip", # remove optional metadata
21 | "--skip-if-larger",
22 | file_path, # the file as input
23 | "--output",
24 | file_path, # the file as output
25 | ]
26 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | ====================
2 | Willow image library
3 | ====================
4 |
5 | Willow is a pure Python library that aims to unite many Python imaging libraries
6 | under a single interface.
7 |
8 | Out of the box, Willow can work with Pillow, Wand or OpenCV. None of these image
9 | libraries are required (but you should have either Pillow or Wand installed to
10 | use most features). It also has a plugin interface which allows you to add
11 | support for more libraries, image formats and operations.
12 |
13 | Willow supports processing of SVG images without any additional libraries.
14 | Format conversion to or from SVG is not currently supported.
15 |
16 |
17 | Index
18 | =====
19 |
20 | .. toctree::
21 | :maxdepth: 2
22 | :titlesonly:
23 |
24 | installation
25 | concepts
26 | guide/index
27 | reference
28 | changelog
29 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 |
4 | # Don't touch our sample test images
5 | exclude: "^tests/images/"
6 | repos:
7 | - repo: https://github.com/pre-commit/pre-commit-hooks
8 | rev: v6.0.0
9 | hooks:
10 | - id: trailing-whitespace
11 | - id: end-of-file-fixer
12 | - id: check-added-large-files
13 | - id: check-merge-conflict
14 | - id: check-toml
15 | - id: check-yaml
16 | - id: debug-statements
17 | - repo: https://github.com/astral-sh/ruff-pre-commit
18 | rev: "v0.14.0"
19 | hooks:
20 | - id: ruff-check
21 | args: [--fix, --exit-non-zero-on-fix]
22 | - id: ruff-format
23 | - repo: https://github.com/pycontribs/mirrors-prettier
24 | rev: v3.6.2
25 | hooks:
26 | - id: prettier
27 | types_or: [json, yaml, markdown, bash, editorconfig, toml]
28 |
--------------------------------------------------------------------------------
/willow/optimizers/cwebp.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from .base import OptimizerBase
4 |
5 | __all__ = ["Cwebp"]
6 |
7 |
8 | class Cwebp(OptimizerBase):
9 | """https://developers.google.com/speed/webp/docs/cwebp"""
10 |
11 | library_name: ClassVar[str] = "cwebp"
12 | image_format: ClassVar[str] = "webp"
13 |
14 | @classmethod
15 | def get_check_library_arguments(cls) -> list[str]:
16 | # running just cwebp gives basic infor and returns a zero exit code
17 | return []
18 |
19 | @classmethod
20 | def get_command_arguments(
21 | cls, file_path: str, progressive: bool = False
22 | ) -> list[str]:
23 | return [
24 | "-m",
25 | "6", # inspect all encoding possibilities for best file size
26 | "-mt", # use multithreading if possible
27 | "-pass",
28 | "10", # max number of passes
29 | "-q",
30 | "75", # compression factor. 100 produces the highest quality.
31 | file_path,
32 | "-o",
33 | file_path,
34 | ]
35 |
--------------------------------------------------------------------------------
/docs/guide/optimize.rst:
--------------------------------------------------------------------------------
1 | Optimizing images
2 | =================
3 |
4 | Aside from the basic optimizations that the Pillow backend provides, Willow supports using dedicated libraries to
5 | optimize images. Out of the box, Willow comes with optimizers for `gifsicle `_,
6 | `jpegoptim `_, `optipng `_,
7 | `pngquant `_ and `cwebp `_.
8 |
9 | They can be enabled by setting the ``WILLOW_OPTIMIZERS`` environment variable to ``true``. To enable a specific
10 | subset of optimizers, set the ``WILLOW_OPTIMIZERS`` environment variable to a comma-separated list of their
11 | library names. For example, to enable only ``jpegoptim`` and ``optipng``:
12 |
13 | .. code-block:: ini
14 |
15 | WILLOW_OPTIMIZERS=jpegoptim,optipng
16 |
17 | or if using Django:
18 |
19 | .. code-block:: python
20 |
21 | WILLOW_OPTIMIZERS = "jpegoptim,optipng"
22 | # or as a list of optimizer library names
23 | WILLOW_OPTIMIZERS = ["jpegoptim", "optipng"]
24 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to PyPI
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | permissions:
8 | contents: read # to fetch code (actions/checkout)
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v5
15 | with:
16 | fetch-depth: 0
17 |
18 | - uses: actions/setup-python@v6
19 | with:
20 | python-version: "3.14"
21 | cache: "pip"
22 | cache-dependency-path: "**/pyproject.toml"
23 |
24 | - name: ⬇️ Install build dependencies
25 | run: |
26 | python -m pip install flit
27 |
28 | - name: 🏗️ Build
29 | run: python -m flit build
30 |
31 | - uses: actions/upload-artifact@v4
32 | with:
33 | path: ./dist
34 | name: dist
35 |
36 | # https://docs.pypi.org/trusted-publishers/using-a-publisher/
37 | pypi-publish:
38 | needs: build
39 | environment: "publish"
40 |
41 | name: ⬆️ Upload release to PyPI
42 | runs-on: ubuntu-latest
43 | permissions:
44 | # Mandatory for trusted publishing
45 | id-token: write
46 | steps:
47 | - uses: actions/download-artifact@v6
48 | with:
49 | name: dist
50 | path: ./dist
51 |
52 | - name: 🚀 Publish package distributions to PyPI
53 | uses: pypa/gh-action-pypi-publish@release/v1
54 | with:
55 | packages-dir: ./dist
56 | print-hash: true
57 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ============
3 |
4 | Willow supports Python 3.9+. It is a pure Python library with no hard
5 | dependencies so it doesn't require a C compiler for a basic installation.
6 |
7 | Installation using ``pip``
8 | --------------------------
9 |
10 | .. code-block:: shell
11 |
12 | pip install Willow
13 |
14 | Installing underlying libraries
15 | -------------------------------
16 |
17 | In order for most features of Willow to work, you need to install either Pillow
18 | or Wand. You can follow the installation instructions for each of them:
19 |
20 | - `Pillow installation `_
21 | - `Wand installation `_
22 |
23 | or you can install them together with Willow when using ``pip``:
24 |
25 | .. code-block:: shell
26 |
27 | pip install Willow[Pillow]
28 | # or
29 | pip install Willow[Wand]
30 |
31 |
32 | Note that Pillow doesn't support animated GIFs and Wand isn't as fast.
33 | Installing both will give best results.
34 |
35 |
36 | HEIC and AVIF support
37 | ^^^^^^^^^^^^^^^^^^^^^
38 |
39 | When using Pillow, you need to install ``pillow-heif`` for HEIC support:
40 |
41 | .. code-block:: shell
42 |
43 | pip install pillow-heif
44 | # or
45 | pip install Willow[heif]
46 |
47 | When using Wand, you will need ImageMagick version 7.0.25 or newer.
48 |
49 | Both Pillow and Wand require ``libheif`` to be installed on your system for full HEIC support.
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014 Torchbox Ltd and individual contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. 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 | 3. Neither the name of Torchbox nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/willow/optimizers/base.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import subprocess
3 | from typing import ClassVar
4 |
5 | logger = logging.getLogger("willow")
6 |
7 |
8 | class OptimizerBase:
9 | library_name: ClassVar[str] = ""
10 | image_format: ClassVar[str] = ""
11 |
12 | class Meta:
13 | abstract = True
14 |
15 | @classmethod
16 | def applies_to(cls, image_format: str) -> bool:
17 | return image_format.lower() == cls.image_format.lower()
18 |
19 | @classmethod
20 | def get_check_library_arguments(cls) -> list[str]:
21 | """
22 | Return a list of arguments to check if the library exists.
23 |
24 | Note: using --help by default as that usually returns a zero exit code
25 | """
26 | return ["--help"]
27 |
28 | @classmethod
29 | def check_library(cls) -> bool:
30 | args = [cls.library_name] + cls.get_check_library_arguments()
31 | try:
32 | subprocess.check_output(args, stderr=subprocess.STDOUT)
33 | return True
34 | except (FileNotFoundError, subprocess.CalledProcessError):
35 | return False
36 |
37 | @classmethod
38 | def get_command_arguments(cls, file_path: str) -> list[str]:
39 | """Return a list of arguments for the given optimizer library."""
40 | return []
41 |
42 | @classmethod
43 | def process(cls, file_path: str):
44 | args = [cls.library_name] + cls.get_command_arguments(file_path)
45 | try:
46 | subprocess.check_output(args, stderr=subprocess.STDOUT)
47 | except subprocess.CalledProcessError as exc:
48 | logger.exception(
49 | "Error optimizing %s with the '%s' library with error: %s",
50 | file_path,
51 | cls.library_name,
52 | exc.output,
53 | )
54 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | import sphinx_wagtail_theme
5 |
6 | from willow import __version__
7 |
8 | sys.path.insert(0, os.path.abspath(".."))
9 |
10 | # -- Project information -----------------------------------------------------
11 |
12 | project = "Willow"
13 | copyright = "2014-present, Torchbox"
14 | author = "Torchbox"
15 | release = __version__
16 | version = __version__
17 |
18 | # -- General configuration ---------------------------------------------------
19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
20 |
21 | extensions = [
22 | "sphinx.ext.autodoc",
23 | "sphinxcontrib.spelling",
24 | "sphinx.ext.intersphinx",
25 | "sphinx_copybutton",
26 | ]
27 |
28 | intersphinx_mapping = {
29 | "python": ("https://docs.python.org/3/", None),
30 | }
31 |
32 | templates_path = ["_templates"]
33 | exclude_patterns = ["_build", ".DS_Store"]
34 |
35 | html_theme = "sphinx_wagtail_theme"
36 | html_theme_path = [sphinx_wagtail_theme.get_html_theme_path()]
37 | html_theme_options = {
38 | "project_name": "Willow",
39 | # "logo": "",
40 | "github_url": "https://github.com/wagtail/Willow/tree/main/docs/",
41 | "footer_links": "",
42 | }
43 | html_last_updated_fmt = "%b %d, %Y"
44 |
45 | html_static_path = ["_static"]
46 |
47 | pygments_style = None # covered by sphinx_wagtail_theme
48 |
49 | spelling_lang = "en_US"
50 | spelling_word_list_filename = "spelling_wordlist.txt"
51 |
52 | # -- Misc --------------------------------------------------------------------
53 |
54 | epub_show_urls = "footnote"
55 | man_pages = [("index", "wagtail", "Willow Documentation", ["Torchbox"], 1)]
56 | texinfo_documents = [
57 | (
58 | "index",
59 | "Willow",
60 | "Willow Documentation",
61 | "Torchbox",
62 | "Willow",
63 | "A Python image library that sits on top of Pillow, Wand and OpenCV",
64 | "Imaging",
65 | ),
66 | ]
67 |
--------------------------------------------------------------------------------
/willow/__init__.py:
--------------------------------------------------------------------------------
1 | from willow.image import Image # noqa: F401
2 |
3 |
4 | def setup():
5 | from xml.etree import ElementTree
6 |
7 | from willow.image import (
8 | AvifImageFile,
9 | BMPImageFile,
10 | GIFImageFile,
11 | HeicImageFile,
12 | IcoImageFile,
13 | JPEGImageFile,
14 | PNGImageFile,
15 | RGBAImageBuffer,
16 | RGBImageBuffer,
17 | SvgImageFile,
18 | TIFFImageFile,
19 | WebPImageFile,
20 | )
21 | from willow.optimizers import Cwebp, Gifsicle, Jpegoptim, Optipng, Pngquant
22 | from willow.plugins import opencv, pillow, wand
23 | from willow.registry import registry
24 | from willow.svg import SvgImage
25 |
26 | registry.register_image_class(JPEGImageFile)
27 | registry.register_image_class(PNGImageFile)
28 | registry.register_image_class(GIFImageFile)
29 | registry.register_image_class(BMPImageFile)
30 | registry.register_image_class(TIFFImageFile)
31 | registry.register_image_class(WebPImageFile)
32 | registry.register_image_class(HeicImageFile)
33 | registry.register_image_class(RGBImageBuffer)
34 | registry.register_image_class(RGBAImageBuffer)
35 | registry.register_image_class(SvgImageFile)
36 | registry.register_image_class(SvgImage)
37 | registry.register_image_class(AvifImageFile)
38 | registry.register_image_class(IcoImageFile)
39 |
40 | registry.register_plugin(pillow)
41 | registry.register_plugin(wand)
42 | registry.register_plugin(opencv)
43 |
44 | registry.register_optimizer(Cwebp)
45 | registry.register_optimizer(Gifsicle)
46 | registry.register_optimizer(Jpegoptim)
47 | registry.register_optimizer(Optipng)
48 | registry.register_optimizer(Pngquant)
49 |
50 | # Prevents etree from prefixing XML tag names with anonymous
51 | # namespaces, e.g. "> $GITHUB_STEP_SUMMARY
87 |
88 | - name: Upload HTML report if check failed.
89 | uses: actions/upload-artifact@v4
90 | with:
91 | name: html-report
92 | path: htmlcov
93 | overwrite: true
94 |
--------------------------------------------------------------------------------
/docs/concepts.rst:
--------------------------------------------------------------------------------
1 | Concepts
2 | ========
3 |
4 | Image classes
5 | -------------
6 |
7 | An image can either be a file, an image loaded into an underlying library or a
8 | simple buffer of pixels. Each of these states has its own Python class
9 | (subclass of :class:`willow.image.Image`).
10 |
11 | For example ``JPEGImageFile``, ``PillowImage`` and ``RGBAImageBuffer`` are three
12 | of the image classes in Willow.
13 |
14 | Operations
15 | ----------
16 |
17 | These are functions that perform actions on an image in a particular state. For
18 | example, ``resize`` and ``crop``.
19 |
20 | Operations can either be defined as methods on the image class or as functions
21 | registered separately.
22 |
23 | All operations are registered in a central registry and will appear as a method
24 | on all other image classes. If it's called from a class that doesn't implement
25 | the operation, the image will be automatically converted to the nearest image
26 | class that supports it and the operation is run on that.
27 |
28 | Operations that alter an image return a new image object instead of altering the
29 | source one. This also means that if a conversion took place, the new image's
30 | class would be different.
31 |
32 | Converters
33 | ----------
34 |
35 | These are functions that convert an image between two image classes. For
36 | example, a converter from ``JPEGImageFile`` to ``PillowImage`` would simply be a
37 | function that calls ``PIL.Image.open`` on the underlying file to get a Pillow
38 | image.
39 |
40 | Like operations, these can either be methods on the image class or registered
41 | separately.
42 |
43 | Each converter has a cost which helps Willow decide which is the best available
44 | image library to use for a particular file format.
45 |
46 | Registry
47 | --------
48 |
49 | The registry is where all image classes, operations and converters are
50 | registered. It contains methods to allow you to register new items and even
51 | override existing ones.
52 |
53 | It also is responsible for finding operations and planning routes between image
54 | classes.
55 |
56 | Plugins
57 | -------
58 |
59 | These are used to group related image classes, operations and converters
60 | together allowing them to be registered as a single unit.
61 |
62 | The convention within Willow is to create a single plugin for each underlying
63 | library. The default ones are "pillow", "wand" and "opencv".
64 |
65 | Plugins can be registered even if the underlying library is not installed. This
66 | allows Willow to generate a useful error message if an operation is requested
67 | that only exists in a plugin without an underlying library.
68 |
69 | .. _concept-optimizers:
70 |
71 | Optimizers
72 | ----------
73 |
74 | :ref:`Optimizers ` are classes that wrap image optimization libraries
75 | such as pngquant or jpegtran and apply to certain image types. They are used to optimize
76 | images after they have been saved.
77 |
78 | All optimizers are registered in a central registry and can be called from the
79 | ``optimize`` method on an image.
80 |
81 | Optimizers will be registered only if the corresponding library is installed and Willow
82 | :doc:`is configured ` to use them.
83 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "Willow"
3 | description = "A Python image library that sits on top of Pillow, Wand and OpenCV"
4 | authors = [{name = "Karl Hobley", email = "karl@kaed.uk"}]
5 | maintainers = [{name = "Wagtail Core team", email = "hello@wagtail.org"}]
6 | readme = "README.md"
7 | license = {file = "LICENSE"}
8 | keywords = ["Imaging"]
9 | classifiers = [
10 | "Development Status :: 5 - Production/Stable",
11 | "Topic :: Multimedia :: Graphics",
12 | "Topic :: Multimedia :: Graphics :: Graphics Conversion",
13 | "Intended Audience :: Developers",
14 | "License :: OSI Approved :: BSD License",
15 | "Operating System :: OS Independent",
16 | "Programming Language :: Python",
17 | "Programming Language :: Python :: 3",
18 | "Programming Language :: Python :: 3 :: Only",
19 | "Programming Language :: Python :: 3.10",
20 | "Programming Language :: Python :: 3.11",
21 | "Programming Language :: Python :: 3.12",
22 | "Programming Language :: Python :: 3.13",
23 | "Programming Language :: Python :: 3.14",
24 | ]
25 |
26 | dynamic = ["version"] # will read __version__ from willow/__init__.py
27 | requires-python = ">=3.10"
28 | dependencies = [
29 | "filetype>=1.0.10,!=1.1.0",
30 | "defusedxml>=0.7,<1.0",
31 | ]
32 |
33 | [project.optional-dependencies]
34 | pillow = ["Pillow>=11.3.0"]
35 | wand = ["Wand>=0.6,<1.0"]
36 | heif = ["pillow-heif>=1.0.0"]
37 |
38 | testing = [
39 | "willow[pillow,wand,heif]",
40 | "coverage[toml]>=7.2.7,<8.0",
41 | "pre-commit>=3.4.0"
42 | ]
43 | docs = [
44 | "Sphinx>=7.0",
45 | "sphinx-wagtail-theme>=6.1.1,<7.0",
46 | "sphinxcontrib-spelling>=8.0,<9.0",
47 | "sphinx_copybutton>=0.5"
48 | ]
49 |
50 | [project.urls]
51 | Source = "https://github.com/wagtail/Willow"
52 | Changelog = "https://willow.wagtail.org/latest/changelog.html"
53 | Documentation = "https://willow.wagtail.org/"
54 |
55 |
56 | [build-system]
57 | requires = ["flit_core >=3.2,<4"]
58 | build-backend = "flit_core.buildapi"
59 |
60 | [tool.flit.module]
61 | name = "willow"
62 |
63 | [tool.flit.sdist]
64 | exclude = [
65 | ".*",
66 | "*.json",
67 | "*.ini",
68 | "*.sh",
69 | "*.yml",
70 | "*.yaml",
71 | "docs/",
72 | "tests/",
73 | "CHANGELOG.txt",
74 | "Dockerfile.py3",
75 | "runtests.py",
76 | ]
77 |
78 | [tool.coverage.run]
79 | branch = true
80 | source_pkgs = ["willow"]
81 |
82 | omit = ["tests/*", "willow/utils/deprecation.py"]
83 |
84 | [tool.coverage.paths]
85 | source = ["willow"]
86 |
87 | [tool.coverage.report]
88 | show_missing = true
89 | ignore_errors = true
90 | skip_empty = true
91 | skip_covered = true
92 | exclude_lines = [
93 | # Have to re-enable the standard pragma
94 | "pragma: no cover",
95 |
96 | # Don't complain about missing debug-only code:
97 | "def __repr__",
98 | "if self.debug",
99 |
100 | # Don't complain if tests don't hit defensive assertion code:
101 | "raise AssertionError",
102 | "raise NotImplementedError",
103 |
104 | # Don't complain if non-runnable code isn't run:
105 | "if 0:",
106 | "if __name__ == .__main__.:",
107 |
108 | # Don't complain about abstract methods, they aren't run:
109 | "@(abc.)?abstractmethod",
110 |
111 | # Nor complain about type checking
112 | "if TYPE_CHECKING:",
113 | ]
114 |
115 | [tool.ruff]
116 | target-version = "py310" # minimum target version
117 |
118 | # E501: Line too long
119 | lint.ignore = ["E501"]
120 | lint.select = [
121 | "E", # pycodestyle errors
122 | "F", # pyflakes
123 | "I", # isort
124 | "T20", # flake8-print
125 | "BLE", # flake8-blind-except
126 | "C4", # flake8-comprehensions
127 | "UP", # pyupgrade
128 | ]
129 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [Willow image library](https://pypi.org/project/Willow/)
2 |
3 | [](https://pypi.org/project/Willow/)
4 | [](https://pypi.org/project/Willow/)
5 | [](https://github.com/wagtail/Willow/actions)
6 |
7 | A wrapper that combines the functionality of multiple Python image libraries into one API.
8 |
9 | [Documentation](https://willow.wagtail.org)
10 |
11 | ## Overview
12 |
13 | Willow is a simple image library that combines the APIs of [Pillow](https://pillow.readthedocs.io/), [Wand](https://docs.wand-py.org) and [OpenCV](https://opencv.org/).
14 | It converts the image between the libraries when necessary.
15 |
16 | Willow currently has basic resize and crop operations, face and feature detection and animated GIF support.
17 | New operations and library integrations can also be [easily implemented](https://willow.wagtail.org/latest/guide/extend.html).
18 |
19 | The library is written in pure Python and supports versions 3.10, 3.11, 3.12, 3.13 and 3.14.
20 |
21 | ## Examples
22 |
23 | ### Resizing an image
24 |
25 | ```python
26 | from willow.image import Image
27 |
28 | f = open('test.png', 'rb')
29 | img = Image.open(f)
30 |
31 | # Resize the image to 100x100 pixels
32 | img = img.resize((100, 100))
33 |
34 | # Save it
35 | with open('test_thumbnail.png', 'wb') as out:
36 | img.save_as_png(out)
37 | ```
38 |
39 | This will open the image file with Pillow or Wand (if Pillow is unavailable).
40 |
41 | It will then resize it to 100x100 pixels and save it back out as a PNG file.
42 |
43 | ### Detecting faces
44 |
45 | ```python
46 | from willow.image import Image
47 |
48 | f = open('photo.png', 'rb')
49 | img = Image.open(f)
50 |
51 | # Find faces
52 | faces = img.detect_faces()
53 | ```
54 |
55 | Like above, the image file will be loaded with either Pillow or Wand.
56 |
57 | As neither Pillow nor Wand support detecting faces, Willow would automatically convert the image to OpenCV and use that to perform the detection.
58 |
59 | ## Available operations
60 |
61 | [Documentation](https://willow.wagtail.org/latest/guide/operations.html)
62 |
63 | | Operation | Pillow | Wand | OpenCV |
64 | | ------------------------------------------------ | ------ | ---- | ------ |
65 | | `get_size()` | ✓ | ✓ | ✓ |
66 | | `get_frame_count()` | ✓\*\* | ✓ | ✓\*\* |
67 | | `resize(size)` | ✓ | ✓ | |
68 | | `crop(rect)` | ✓ | ✓ | |
69 | | `rotate(angle)` | ✓ | ✓ | |
70 | | `set_background_color_rgb(color)` | ✓ | ✓ | |
71 | | `transform_colorspace_to_srgb(rendering_intent)` | ✓ | | |
72 | | `auto_orient()` | ✓ | ✓ | |
73 | | `save_as_jpeg(file, quality)` | ✓ | ✓ | |
74 | | `save_as_png(file)` | ✓ | ✓ | |
75 | | `save_as_gif(file)` | ✓ | ✓ | |
76 | | `save_as_webp(file, quality)` | ✓ | ✓ | |
77 | | `save_as_heic(file, quality, lossless)` | ✓⁺ | | |
78 | | `save_as_avif(file, quality, lossless)` | ✓ | ✓ | |
79 | | `save_as_ico(file)` | ✓ | ✓ | |
80 | | `has_alpha()` | ✓ | ✓ | ✓\* |
81 | | `has_animation()` | ✓\* | ✓ | ✓\* |
82 | | `get_pillow_image()` | ✓ | | |
83 | | `get_wand_image()` | | ✓ | |
84 | | `detect_features()` | | | ✓ |
85 | | `detect_faces(cascade_filename)` | | | ✓ |
86 |
87 | \* Always returns `False`
88 |
89 | \*\* Always returns `1`
90 |
91 | ⁺ Requires the [pillow-heif](https://pypi.org/project/pillow-heif/) library
92 |
--------------------------------------------------------------------------------
/willow/plugins/opencv.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from willow.image import Image, RGBImageBuffer
4 |
5 |
6 | def _cv2():
7 | try:
8 | import cv2
9 | except ImportError:
10 | from cv import cv2
11 | return cv2
12 |
13 |
14 | def _numpy():
15 | import numpy
16 |
17 | return numpy
18 |
19 |
20 | class BaseOpenCVImage(Image):
21 | def __init__(self, image, size):
22 | self.image = image
23 | self.size = size
24 |
25 | @classmethod
26 | def check(cls):
27 | _cv2()
28 |
29 | @Image.operation
30 | def get_size(self):
31 | return self.size
32 |
33 | @Image.operation
34 | def get_frame_count(self):
35 | # Animation is not supported by OpenCV
36 | return 1
37 |
38 | @Image.operation
39 | def has_alpha(self):
40 | # Alpha is not supported by OpenCV
41 | return False
42 |
43 | @Image.operation
44 | def has_animation(self):
45 | # Animation is not supported by OpenCV
46 | return False
47 |
48 |
49 | class OpenCVColorImage(BaseOpenCVImage):
50 | @classmethod
51 | def check(cls):
52 | super().check()
53 | _numpy()
54 |
55 | @classmethod
56 | @Image.converter_from(RGBImageBuffer)
57 | def from_buffer_rgb(cls, image_buffer):
58 | """
59 | Converts a Color Image buffer into a numpy array suitable for use with OpenCV
60 | """
61 | numpy = _numpy()
62 | cv2 = _cv2()
63 |
64 | image = numpy.frombuffer(image_buffer.data, dtype=numpy.uint8)
65 | image = image.reshape(image_buffer.size[1], image_buffer.size[0], 3)
66 | image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
67 | return cls(image, image_buffer.size)
68 |
69 |
70 | class OpenCVGrayscaleImage(BaseOpenCVImage):
71 | face_haar_flags = 0
72 | face_min_neighbors = 3
73 | face_haar_scale = 1.1
74 | face_min_size = (40, 40)
75 |
76 | @Image.operation
77 | def detect_features(self):
78 | """
79 | Find interesting features of an image suitable for cropping to.
80 | """
81 | numpy = _numpy()
82 | cv2 = _cv2()
83 | points = cv2.goodFeaturesToTrack(self.image, 20, 0.04, 1.0)
84 | if points is None:
85 | return []
86 | else:
87 | points = numpy.reshape(
88 | points, (-1, 2)
89 | ) # Numpy returns it with an extra third dimension
90 | return points.tolist()
91 |
92 | @Image.operation
93 | def detect_faces(self, cascade_filename="haarcascade_frontalface_alt2.xml"):
94 | """
95 | Run OpenCV face detection on the image. Returns a list of coordinates representing a box around each face.
96 | """
97 | cv2 = _cv2()
98 | cascade_filename = self._find_cascade(cascade_filename)
99 | cascade = cv2.CascadeClassifier(cascade_filename)
100 | equalised_image = cv2.equalizeHist(self.image)
101 | faces = cascade.detectMultiScale(
102 | equalised_image,
103 | self.face_haar_scale,
104 | self.face_min_neighbors,
105 | self.face_haar_flags,
106 | self.face_min_size,
107 | )
108 | return [
109 | (
110 | face[0],
111 | face[1],
112 | face[0] + face[2],
113 | face[1] + face[3],
114 | )
115 | for face in faces
116 | ]
117 |
118 | def _find_cascade(self, cascade_filename):
119 | """
120 | Find the requested OpenCV cascade file. If a relative path was provided, check local cascades directory.
121 | """
122 | if not os.path.isabs(cascade_filename):
123 | cascade_filename = os.path.join(
124 | os.path.dirname(os.path.dirname(__file__)),
125 | "data/cascades",
126 | cascade_filename,
127 | )
128 | return cascade_filename
129 |
130 | @classmethod
131 | @Image.converter_from(OpenCVColorImage)
132 | def from_color(cls, colour_image):
133 | """
134 | Convert OpenCVColorImage to an OpenCVGrayscaleImage.
135 | """
136 | cv2 = _cv2()
137 | image = cv2.cvtColor(colour_image.image, cv2.COLOR_BGR2GRAY)
138 | return cls(image, colour_image.size)
139 |
140 |
141 | willow_image_classes = [OpenCVColorImage, OpenCVGrayscaleImage]
142 |
--------------------------------------------------------------------------------
/docs/guide/operations.rst:
--------------------------------------------------------------------------------
1 | Basic image operations
2 | ======================
3 |
4 | Here's where Willow gets fancy, all operations in all plugins are available as
5 | methods on every image. If an operation is called but doesn't exist in the
6 | image's current class, a conversion will be performed under the hood.
7 |
8 | Willow will do it's best to maintain the quality of the image, it'll decide how
9 | to convert based on the images format and whether it has animation or transparency.
10 | However it is not always easy
11 |
12 | This means you can focus on making the code look clear and leave Willow to choose
13 | which plugin is best to perform an operation.
14 |
15 | Getting the image size
16 | ----------------------
17 |
18 | You can call the :meth:`~Image.get_size` method which returns the width and
19 | height as a tuple of two integers:
20 |
21 | .. code-block:: python
22 |
23 | # For example, 'i' is a 200x200 pixel image
24 | i.get_size() == (200, 200)
25 |
26 | For animated GIFs, you can get the number of frames by calling the :meth:`Image.get_frame_count` method:
27 |
28 | .. code-block:: python
29 |
30 | i.get_frame_count() == 34
31 |
32 | Resizing images
33 | ---------------
34 |
35 | To resize an image, call the :meth:`~Image.resize` method. This stretches the
36 | image to fit the new size.
37 |
38 | It takes a single argument, a two element sequence of integers containing the
39 | width and height of the final image.
40 |
41 | It returns a new :class:`~Image` object containing the resized image. The
42 | original image is not modified.
43 |
44 | .. code-block:: python
45 |
46 | i = i.resize((100, 100))
47 |
48 | isinstance(i, Image)
49 | i.get_size() == (100, 100)
50 |
51 | Rotating images
52 | ---------------
53 |
54 | To rotate an image, call the :meth:`~Image.rotate` method. This rotates the image clockwise, by a multiple of 90 degrees (i.e 90, 180, 270).
55 |
56 | It returns a new :class:`~Image` object containing the rotated image. The
57 | original image is not modified.
58 |
59 | .. code-block:: python
60 |
61 | # in this case, assume 'i' is a 300x150 pixel image
62 | i = i.rotate(90)
63 | isinstance(i, Image)
64 | i.get_size() == (150, 300)
65 |
66 |
67 | Cropping images
68 | ---------------
69 |
70 | To crop an image, call the :meth:`~Image.crop` method. This cuts the specified
71 | rectangle from the source image.
72 |
73 | It takes a single argument, a four element sequence of integers containing the
74 | location of the left, top, right and bottom edges to cut out.
75 |
76 | It returns a new :class:`~Image` object containing the cropped region. The
77 | original image is not modified.
78 |
79 | .. code-block:: python
80 |
81 | i = i.crop((100, 100, 300, 300))
82 |
83 | isinstance(i, Image)
84 | i.get_size() == (200, 200)
85 |
86 | Setting a background color
87 | --------------------------
88 |
89 | If the image has transparency, you can replace the transparency with a solid
90 | background color using the :meth:`~Image.set_background_color_rgb` method.
91 |
92 | It takes the background color as a three element tuple of integers between
93 | 0 - 255 (representing the red, green and blue channels respectively).
94 |
95 | It returns a new :class:`~Image` object containing the background color and
96 | the alpha channel removed. The original image is not modified.
97 |
98 | .. code-block:: python
99 |
100 | # Sets background color to white
101 | i = i.set_background_color_rgb((255, 255, 255))
102 |
103 | isinstance(i, Image)
104 | i.has_alpha() == False
105 |
106 | Detecting features
107 | ------------------
108 |
109 | Feature detection in Willow is provided by OpenCV so make sure it's installed first.
110 |
111 | To detect features in an image, use the :meth:`~Image.detect_features` operation.
112 | This will return a list of tuples, containing the x and y coordinates of each
113 | feature that was detected in the image.
114 |
115 | .. code-block:: python
116 |
117 | features = i.detect_features()
118 |
119 | features == [
120 | (12, 53),
121 | (74, 44),
122 | ...
123 | ]
124 |
125 | Under the hood, this uses OpenCV's GoodFeaturesToTrack_ function that finds the
126 | prominent corners in the image.
127 |
128 | .. _GoodFeaturesToTrack: https://docs.opencv.org/3.0-beta/modules/imgproc/doc/feature_detection.html#goodfeaturestotrack
129 |
130 | Detecting faces
131 | ---------------
132 |
133 | Face detection in Willow is provided by OpenCV so make sure it's installed first.
134 |
135 | To detect features in an image, use the :meth:`~Image.detect_faces` operation.
136 | This will return a list of tuples, containing the left, top, right and bottom
137 | positions in the image where each face appears.
138 |
139 | .. code-block:: python
140 |
141 | faces = i.detect_faces()
142 |
143 | faces == [
144 | (12, 53, 65, 102),
145 | (1, 44, 74, 93),
146 | ...
147 | ]
148 |
149 | Under the hood, this uses OpenCV's HaarDetectObjects_ function that performs
150 | Haar cascade classification on the image. The default cascade file that gets
151 | used is ``haarcascade_frontalface_alt2`` from OpenCV, but this can be changed
152 | by setting the ``cascade_filename`` keyword argument to an absolute path
153 | pointing to the file:
154 |
155 | .. code-block:: python
156 |
157 | import os
158 |
159 | faces = i.detect_faces(cascade_filename=os.abspath('cascades/my_cascade_file.xml'))
160 |
161 | faces == [
162 | (12, 53, 65, 102),
163 | (1, 44, 74, 93),
164 | ...
165 | ]
166 |
167 | .. _HaarDetectObjects: https://docs.opencv.org/2.4/modules/objdetect/doc/cascade_classification.html#CvSeq*%20cvHaarDetectObjects%28const%20CvArr*%20image,%20CvHaarClassifierCascade*%20cascade,%20CvMemStorage*%20storage,%20double%20scale_factor,%20int%20min_neighbors,%20int%20flags,%20CvSize%20min_size,%20CvSize%20max_size%29
168 |
--------------------------------------------------------------------------------
/tests/test_optimizers.py:
--------------------------------------------------------------------------------
1 | import io
2 | import os
3 | import unittest
4 | from subprocess import STDOUT, CalledProcessError
5 | from tempfile import NamedTemporaryFile
6 | from unittest import TestCase, mock
7 |
8 | from willow.optimizers import Cwebp, Gifsicle, Jpegoptim, Pngquant
9 | from willow.optimizers.base import OptimizerBase
10 | from willow.registry import WillowRegistry
11 |
12 |
13 | class OptimizerTest(TestCase):
14 | @classmethod
15 | def setUpClass(cls) -> None:
16 | class DummyOptimizer(OptimizerBase):
17 | library_name = "dummy"
18 | image_format = "FOO"
19 |
20 | cls.DummyOptimizer = DummyOptimizer
21 |
22 | def setUp(self) -> None:
23 | self.registry = WillowRegistry()
24 |
25 | @mock.patch("willow.optimizers.base.subprocess.check_output")
26 | def test_check_library(self, mock_check_output):
27 | self.assertTrue(self.DummyOptimizer.check_library())
28 |
29 | @mock.patch("willow.optimizers.base.subprocess.check_output")
30 | def test_check_library_fail(self, mock_check_output):
31 | mock_check_output.side_effect = CalledProcessError(-1, "dummy")
32 | self.assertFalse(self.DummyOptimizer.check_library())
33 |
34 | mock_check_output.side_effect = FileNotFoundError
35 | self.assertFalse(self.DummyOptimizer.check_library())
36 |
37 | def test_applies_to(self):
38 | self.assertTrue(self.DummyOptimizer.applies_to("foo"))
39 | self.assertTrue(self.DummyOptimizer.applies_to("FOO"))
40 | self.assertFalse(self.DummyOptimizer.applies_to("JPEG"))
41 |
42 | def test_get_check_library_arguments(self):
43 | self.assertEqual(self.DummyOptimizer.get_check_library_arguments(), ["--help"])
44 |
45 | def test_get_command_arguments(self):
46 | self.assertEqual(self.DummyOptimizer.get_command_arguments("file.png"), [])
47 |
48 | @mock.patch("willow.optimizers.base.subprocess.check_output")
49 | def test_process(self, mock_check_output):
50 | self.DummyOptimizer.process("file.png")
51 | mock_check_output.assert_called_once_with(["dummy"], stderr=STDOUT)
52 |
53 | @mock.patch("willow.optimizers.base.subprocess.check_output")
54 | def test_process_logs_any_issue(self, mock_check_output):
55 | # Simulates a CalledProcessError and tests that we log the error
56 | mock_check_output.side_effect = CalledProcessError(1, "dummy")
57 | with self.assertLogs("willow", level="ERROR") as log_output:
58 | self.DummyOptimizer.process("file.png")
59 |
60 | self.assertIn(
61 | "Error optimizing file.png with the 'dummy' library", log_output.output[0]
62 | )
63 |
64 |
65 | class DefaultOptimizerTestBase:
66 | @classmethod
67 | def setUpClass(cls) -> None:
68 | with open(f"tests/images/optimizers/original.{cls.extension}", "rb") as f:
69 | cls.original_size = os.fstat(f.fileno()).st_size
70 | cls.original_image = f.read()
71 |
72 | with open(f"tests/images/optimizers/optimized.{cls.extension}", "rb") as f:
73 | f.seek(0, io.SEEK_END)
74 | cls.optimized_size = os.fstat(f.fileno()).st_size
75 | cls.optimized_image = f.read()
76 |
77 | def test_process_optimizes_image(self):
78 | try:
79 | with NamedTemporaryFile(delete=False) as named_temporary_file:
80 | named_temporary_file.write(self.original_image)
81 | image_file = named_temporary_file.name
82 |
83 | self.optimizer.process(image_file)
84 |
85 | with open(image_file, "rb") as f:
86 | self.assertAlmostEqual(
87 | self.optimized_size, os.fstat(f.fileno()).st_size, delta=60
88 | )
89 | finally:
90 | os.unlink(image_file)
91 |
92 |
93 | @unittest.skipUnless(Gifsicle.check_library(), "gifsicle not installed")
94 | class GifsicleOptimizer(DefaultOptimizerTestBase, TestCase):
95 | extension = "gif"
96 | optimizer = Gifsicle
97 |
98 | def test_applies_to(self):
99 | self.assertTrue(Gifsicle.applies_to("gif"))
100 | for ext in ("png", "jpeg", "webp", "tiff", "bmp"):
101 | self.assertFalse(Gifsicle.applies_to(ext))
102 |
103 | def test_get_command_arguments(self):
104 | self.assertListEqual(
105 | Gifsicle.get_command_arguments("file.gif"), ["-b", "-O3", "file.gif"]
106 | )
107 |
108 |
109 | @unittest.skipUnless(Jpegoptim.check_library(), "jpegoptim not installed")
110 | class JpegoptimOptimizer(DefaultOptimizerTestBase, TestCase):
111 | extension = "jpg"
112 | optimizer = Jpegoptim
113 |
114 | def test_applies_to(self):
115 | self.assertTrue(Jpegoptim.applies_to("jpeg"))
116 | for ext in ("png", "gif", "webp", "tiff", "bmp"):
117 | self.assertFalse(Jpegoptim.applies_to(ext))
118 |
119 | def test_get_command_arguments(self):
120 | self.assertListEqual(
121 | Jpegoptim.get_command_arguments("file.jpg"),
122 | ["--strip-all", "--max=85", "--all-progressive", "file.jpg"],
123 | )
124 |
125 |
126 | @unittest.skipUnless(Pngquant.check_library(), "pngquant not installed")
127 | class PngquantOptimizer(DefaultOptimizerTestBase, TestCase):
128 | extension = "png"
129 | optimizer = Pngquant
130 |
131 | def test_applies_to(self):
132 | self.assertTrue(Pngquant.applies_to("png"))
133 | for ext in ("gif", "jpeg", "webp", "tiff", "bmp"):
134 | self.assertFalse(Pngquant.applies_to(ext))
135 |
136 | def test_get_command_arguments(self):
137 | self.assertListEqual(
138 | Pngquant.get_command_arguments("file.png"),
139 | [
140 | "--force",
141 | "--strip",
142 | "--skip-if-larger",
143 | "file.png",
144 | "--output",
145 | "file.png",
146 | ],
147 | )
148 |
149 |
150 | @unittest.skipUnless(Cwebp.check_library(), "cwebp not installed")
151 | class CwebpOptimizer(DefaultOptimizerTestBase, TestCase):
152 | extension = "webp"
153 | optimizer = Cwebp
154 |
155 | def test_applies_to(self):
156 | self.assertTrue(Cwebp.applies_to("webp"))
157 | for ext in ("png", "jpeg", "gif", "tiff", "bmp"):
158 | self.assertFalse(Cwebp.applies_to(ext))
159 |
160 | def test_get_command_arguments(self):
161 | self.assertListEqual(
162 | Cwebp.get_command_arguments("file.webp"),
163 | [
164 | "-m",
165 | "6",
166 | "-mt",
167 | "-pass",
168 | "10",
169 | "-q",
170 | "75",
171 | "file.webp",
172 | "-o",
173 | "file.webp",
174 | ],
175 | )
176 |
177 | def get_check_library_command_arguments(self):
178 | self.assertListEqual(
179 | Cwebp.get_check_library_arguments(),
180 | [],
181 | )
182 |
--------------------------------------------------------------------------------
/docs/guide/extend.rst:
--------------------------------------------------------------------------------
1 | Extending Willow
2 | ================
3 |
4 | This section describes how to extend Willow with custom operations, image formats
5 | and plugins.
6 |
7 | Don't forget to look at the :doc:`concepts ` section first!
8 |
9 | Implementing new operations
10 | ---------------------------
11 |
12 | You can add operations to any existing image class and register them by calling the
13 | :meth:`Registry.register_operation` method passing it the image class, name of
14 | the operation and the function to call when the operation is used.
15 |
16 | For example, let's implement a ``blur`` operation for both the
17 | :class:`~willow.plugins.pillow.PillowImage` and :class:`~willow.plugins.wand.WandImage`
18 | classes:
19 |
20 | .. code-block:: python
21 |
22 | from willow.registry import registry
23 | from willow.plugins.pillow import PillowImage
24 | from willow.plugins.wand import WandImage
25 |
26 | def pillow_blur(image):
27 | from PIL import ImageFilter
28 |
29 | blurred_image = image.image.filter(ImageFilter.BLUR)
30 | return PillowImage(blurred_image)
31 |
32 | def wand_blur(image):
33 | # Wand modifies images in place so clone it first to prevent
34 | # altering the original image
35 | blurred_image = image.image.clone()
36 | blurred_image.gaussian_blur()
37 | return WandImage(blurred_image)
38 |
39 |
40 | # Register the operations in Willow
41 |
42 | registry.register_operation(PillowImage, 'blur', pillow_blur)
43 | registry.register_operation(WandImage, 'blur', wand_blur)
44 |
45 | It is not required to support both :class:`~willow.plugins.pillow.PillowImage`
46 | and :class:`~willow.plugins.wand.WandImage` but it's recommended that libraries
47 | support both for maximum compatibility. You must support Wand if you need
48 | animated GIF support.
49 |
50 | Implementing custom image classes
51 | ---------------------------------
52 |
53 | You can create your own image classes and register them by calling the
54 | :meth:`Registry.register_image_class` method. All image classes must be a
55 | subclass of :class:`willow.image.Image`.
56 |
57 | Methods on image classes can be decorated with ``@Image.operation``,
58 | ``@Image.converter_from`` or ``@Image.converter_to`` which will make Willow
59 | automatically register those methods as operations or converters.
60 |
61 | For example, let's implement our own image class for Pillow:
62 |
63 | .. code-block:: python
64 |
65 | from __future__ import absolute_import
66 |
67 | import PIL.Image
68 |
69 | from willow.image import (
70 | Image,
71 | JPEGImageFile,
72 | PNGImageFile,
73 | GIFImageFile,
74 | )
75 |
76 |
77 | class NewPillowImage(Image):
78 | def __init__(self, image):
79 | self.image = image
80 |
81 |
82 | # Informational operations
83 |
84 | @Image.operation
85 | def get_size(self):
86 | return self.image.size
87 |
88 | @Image.operation
89 | def has_alpha(self):
90 | img = self.image
91 | return img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info)
92 |
93 | @Image.operation
94 | def has_animation(self):
95 | # Animation is not supported by PIL
96 | return False
97 |
98 |
99 | # Resize and crop operations
100 |
101 | @Image.operation
102 | def resize(self, size):
103 | return PillowImage(image.resize(size, PIL.Image.ANTIALIAS))
104 |
105 | @Image.operation
106 | def crop(self, rect):
107 | return PillowImage(self.image.crop(rect))
108 |
109 |
110 | # Converter from supported file formats, this is where the image is opened
111 |
112 | # Pillow doesn't support GIFs very well. Adding a cost will make Willow try
113 | # a different image class first. The default cost for all converters is 100.
114 |
115 | @classmethod
116 | @Image.converter_from(JPEGImageFile)
117 | @Image.converter_from(PNGImageFile)
118 | @Image.converter_from(GIFImageFile, cost=200)
119 | @Image.converter_from(BMPImageFile)
120 | def open(cls, image_file):
121 | image_file.f.seek(0)
122 | image = PIL.Image.open(image_file.f)
123 |
124 | return cls(image)
125 |
126 | The image class can then be registered by calling :meth:`Registry.register_image_class`:
127 |
128 | .. code-block:: python
129 |
130 | from willow.registry import registry
131 |
132 | from newpillow import NewPillowImage
133 |
134 | registry.register_image_class(NewPillowImage)
135 |
136 | This will also register all operations and converters defined on the class.
137 |
138 |
139 | Plugins
140 | -------
141 |
142 | Plugins allow multiple image classes and/or operations to be registered together.
143 | They are Python modules with any of the following attributes defined:
144 | ``willow_image_classes``, ``willow_operations`` or ``willow_converters``.
145 |
146 | For example, we can convert the Python module in the example above into a Willow
147 | plugin by adding the following line at the bottom of the file:
148 |
149 | .. code-block:: python
150 |
151 | willow_image_classes = [NewPillowImage]
152 |
153 | It can now be registered using the :meth:`Registry.register_plugin` method:
154 |
155 | .. code-block:: python
156 |
157 | from willow.registry import registry
158 |
159 | import newpillow
160 |
161 | registry.register_plugin(newpillow)
162 |
163 |
164 | .. _custom-optimizers:
165 |
166 | Image optimizers
167 | ================
168 |
169 | You can define new image optimizers by subclassing :class:`willow.optimizers.base.ImageOptimizer` and defining the
170 | ``library_name`` and ``image_format`` attributes.
171 |
172 | .. code-block:: python
173 |
174 | from willow.optimizers.base import OptimizerBase
175 |
176 | class SvgoOptimizer(OptimizerBase):
177 | library_name = "svgo"
178 | image_format = "svg"
179 |
180 | It can now be registered by calling the :meth:`Registry.register_optimizer()` method passing in the optimizer class name
181 |
182 | .. code-block:: python
183 | :emphasize-lines: 2,8
184 |
185 | from willow.optimizers.base import OptimizerBase
186 | from willow.registry import registry
187 |
188 | class SvgoOptimizer(ImageOptimizer):
189 | library_name = "svgo"
190 | image_format = "svg"
191 |
192 | registry.register_optimizer(SvgoOptimizer)
193 |
194 | Note that the registry will only register the optimizer if the library is available on the system. It does so by
195 | calling :meth:`OptimizerBase.check_library()` which will call the optimizer library with the ``--help`` attribute
196 | and return false if the library is not found or calling the library returns a non-zero exit code.
197 |
198 | To ensure the registry can check your library, you can override the :meth:`OptimizerBase.get_check_library_arguments()`
199 | method to use different arguments that will return a zero exit code.
200 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " devhelp to make HTML files and a Devhelp project"
34 | @echo " epub to make an epub"
35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
36 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
38 | @echo " text to make text files"
39 | @echo " man to make manual pages"
40 | @echo " texinfo to make Texinfo files"
41 | @echo " info to make Texinfo files and run them through makeinfo"
42 | @echo " gettext to make PO message catalogs"
43 | @echo " changes to make an overview of all changed/added/deprecated items"
44 | @echo " xml to make Docutils-native XML files"
45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
46 | @echo " linkcheck to check all external links for integrity"
47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
48 |
49 | clean:
50 | rm -rf $(BUILDDIR)/*
51 |
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | spelling:
58 | $(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling
59 | @echo
60 | @echo "Spellcheck complete."
61 |
62 | dirhtml:
63 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
64 | @echo
65 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
66 |
67 | singlehtml:
68 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
69 | @echo
70 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
71 |
72 | pickle:
73 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
74 | @echo
75 | @echo "Build finished; now you can process the pickle files."
76 |
77 | json:
78 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
79 | @echo
80 | @echo "Build finished; now you can process the JSON files."
81 |
82 | htmlhelp:
83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
84 | @echo
85 | @echo "Build finished; now you can run HTML Help Workshop with the" \
86 | ".hhp project file in $(BUILDDIR)/htmlhelp."
87 |
88 | qthelp:
89 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
90 | @echo
91 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
92 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
93 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Wagtail.qhcp"
94 | @echo "To view the help file:"
95 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Wagtail.qhc"
96 |
97 | devhelp:
98 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
99 | @echo
100 | @echo "Build finished."
101 | @echo "To view the help file:"
102 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Wagtail"
103 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Wagtail"
104 | @echo "# devhelp"
105 |
106 | epub:
107 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
108 | @echo
109 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
110 |
111 | latex:
112 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
113 | @echo
114 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
115 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
116 | "(use \`make latexpdf' here to do that automatically)."
117 |
118 | latexpdf:
119 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
120 | @echo "Running LaTeX files through pdflatex..."
121 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
122 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
123 |
124 | latexpdfja:
125 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
126 | @echo "Running LaTeX files through platex and dvipdfmx..."
127 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
128 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
129 |
130 | text:
131 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
132 | @echo
133 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
134 |
135 | man:
136 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
137 | @echo
138 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
139 |
140 | texinfo:
141 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
142 | @echo
143 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
144 | @echo "Run \`make' in that directory to run these through makeinfo" \
145 | "(use \`make info' here to do that automatically)."
146 |
147 | info:
148 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
149 | @echo "Running Texinfo files through makeinfo..."
150 | make -C $(BUILDDIR)/texinfo info
151 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
152 |
153 | gettext:
154 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
155 | @echo
156 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
157 |
158 | changes:
159 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
160 | @echo
161 | @echo "The overview file is in $(BUILDDIR)/changes."
162 |
163 | linkcheck:
164 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
165 | @echo
166 | @echo "Link check complete; look for any errors in the above output " \
167 | "or in $(BUILDDIR)/linkcheck/output.txt."
168 |
169 | doctest:
170 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
171 | @echo "Testing of doctests in the sources finished, look at the " \
172 | "results in $(BUILDDIR)/doctest/output.txt."
173 |
174 | xml:
175 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
176 | @echo
177 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
178 |
179 | pseudoxml:
180 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
181 | @echo
182 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
183 |
--------------------------------------------------------------------------------
/tests/test_svg_coordinate_transforms.py:
--------------------------------------------------------------------------------
1 | from functools import partial
2 |
3 | from willow.svg import (
4 | SvgImage,
5 | ViewportToUserSpaceTransform,
6 | get_viewport_to_user_space_transform,
7 | )
8 |
9 | from .test_svg_image import SvgWrapperTestCase
10 |
11 |
12 | class ViewportToUserSpaceTransformTestCase(SvgWrapperTestCase):
13 | def test_get_transform_same_ratio(self):
14 | svg = SvgImage(
15 | self.get_svg_wrapper(width=100, height=100, view_box="0 0 100 100")
16 | )
17 | transform = get_viewport_to_user_space_transform(svg)
18 | self.assertEqual(transform, ViewportToUserSpaceTransform(1, 1, 0, 0))
19 |
20 | def test_get_transform_equivalent_ratios(self):
21 | svg = SvgImage(self.get_svg_wrapper(width=90, height=30, view_box="0 0 9 3"))
22 | transform = get_viewport_to_user_space_transform(svg)
23 | self.assertEqual(transform, ViewportToUserSpaceTransform(10, 10, 0, 0))
24 |
25 | def test_get_transform_equivalent_ratios_floats(self):
26 | svg = SvgImage(
27 | self.get_svg_wrapper(width=95, height=35, view_box="0 0 9.5 3.5")
28 | )
29 | transform = get_viewport_to_user_space_transform(svg)
30 | self.assertEqual(transform, ViewportToUserSpaceTransform(10, 10, 0, 0))
31 |
32 | def test_preserve_aspect_ratio_none(self):
33 | svg = SvgImage(
34 | self.get_svg_wrapper(
35 | width=100,
36 | height=100,
37 | view_box="0 0 50 80",
38 | preserve_aspect_ratio="none",
39 | )
40 | )
41 | transform = get_viewport_to_user_space_transform(svg)
42 | self.assertEqual(transform, ViewportToUserSpaceTransform(2, 1.25, 0, 0))
43 |
44 |
45 | class PreserveAspectRatioMeetTestCase(SvgWrapperTestCase):
46 | def test_portrait_view_box(self):
47 | # With "meet", the scaling factor will be min(scale_x,
48 | # scale_y). In the case of a portrait ratio view box in a
49 | # square viewport, this will be scale_y
50 | svg_wrapper = partial(
51 | self.get_svg_wrapper, width=100, height=100, view_box="0 0 50 80"
52 | )
53 | params = [
54 | ("xMinYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
55 | ("xMinYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
56 | ("xMinYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
57 | ("xMidYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, -18.75, 0)),
58 | ("xMidYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, -18.75, 0)),
59 | ("xMidYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, -18.75, 0)),
60 | ("xMaxYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, -37.5, 0)),
61 | ("xMaxYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, -37.5, 0)),
62 | ("xMaxYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, -37.5, 0)),
63 | ]
64 | for preserve_aspect_ratio, expected_result in params:
65 | with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
66 | svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
67 | self.assertEqual(
68 | get_viewport_to_user_space_transform(svg), expected_result
69 | )
70 |
71 | def test_landscape_view_box(self):
72 | # With a landscape orientation view box, we will use scale_x
73 | # as the scaling factor
74 | svg_wrapper = partial(
75 | self.get_svg_wrapper, width=100, height=100, view_box="0 0 80 50"
76 | )
77 | params = [
78 | ("xMinYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
79 | ("xMidYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
80 | ("xMaxYMin meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, 0)),
81 | ("xMinYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -18.75)),
82 | ("xMidYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -18.75)),
83 | ("xMaxYMid meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -18.75)),
84 | ("xMinYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -37.5)),
85 | ("xMidYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -37.5)),
86 | ("xMaxYMax meet", ViewportToUserSpaceTransform(1.25, 1.25, 0, -37.5)),
87 | ]
88 | for preserve_aspect_ratio, expected_result in params:
89 | with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
90 | svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
91 | self.assertEqual(
92 | get_viewport_to_user_space_transform(svg), expected_result
93 | )
94 |
95 |
96 | class PreserveAspectRatioSliceTestCase(SvgWrapperTestCase):
97 | def test_portrait_view_box(self):
98 | # With "slice", the scaling factor will be max(scale_x,
99 | # scale_y). In the case of a portrait ratio view box in a
100 | # square viewport, this will be scale_x
101 | svg_wrapper = partial(
102 | self.get_svg_wrapper, width=100, height=100, view_box="0 0 40 80"
103 | )
104 | params = [
105 | ("xMinYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
106 | ("xMidYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
107 | ("xMaxYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
108 | ("xMinYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 50)),
109 | ("xMidYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 50)),
110 | ("xMaxYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 50)),
111 | ("xMinYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 100)),
112 | ("xMidYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 100)),
113 | ("xMaxYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 100)),
114 | ]
115 | for preserve_aspect_ratio, expected_result in params:
116 | with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
117 | svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
118 | self.assertEqual(
119 | get_viewport_to_user_space_transform(svg), expected_result
120 | )
121 |
122 | def test_landscape_view_box(self):
123 | # With a landscape orientation view box, we will use scale_y
124 | # as the scaling factor
125 | svg_wrapper = partial(
126 | self.get_svg_wrapper, width=100, height=100, view_box="0 0 80 40"
127 | )
128 | params = [
129 | ("xMinYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
130 | ("xMinYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
131 | ("xMinYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 0, 0)),
132 | ("xMidYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 50, 0)),
133 | ("xMidYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 50, 0)),
134 | ("xMidYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 50, 0)),
135 | ("xMaxYMin slice", ViewportToUserSpaceTransform(2.5, 2.5, 100, 0)),
136 | ("xMaxYMid slice", ViewportToUserSpaceTransform(2.5, 2.5, 100, 0)),
137 | ("xMaxYMax slice", ViewportToUserSpaceTransform(2.5, 2.5, 100, 0)),
138 | ]
139 | for preserve_aspect_ratio, expected_result in params:
140 | with self.subTest(preserve_aspect_ratio=preserve_aspect_ratio):
141 | svg = SvgImage(svg_wrapper(preserve_aspect_ratio=preserve_aspect_ratio))
142 | self.assertEqual(
143 | get_viewport_to_user_space_transform(svg), expected_result
144 | )
145 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | 1.13.0 (UNRELEASED)
5 | -------------------
6 |
7 | - Nothing yet.
8 |
9 | 1.12.0 (2025-10-26)
10 | -------------------
11 |
12 | - Add support for Python 3.14 (Storm Heg)
13 | - Drop support for Python 3.9 (Storm Heg)
14 | - The minimum required pillow-heif version is now 1.0.0 (Storm Heg)
15 | - Add support for Pillow 12 and beyond, removed hard upper bound (Storm Heg)
16 |
17 | 1.11.0 (2025-07-16)
18 | -------------------
19 |
20 | - Switch AVIF support to use Pillow's built-in AVIF support instead of ``pillow_heif`` (Storm Heg)
21 | - Minimum required Pillow version is now 11.3.0 (Storm Heg)
22 | - Unpin ``pillow_heif`` upper version limit, no longer required (Storm Heg)
23 | - Adopt (deprecated) Pillow PNG channel clipping as expected behavior, see PR `#171 `_ for details (Storm Heg)
24 |
25 | 1.10.0 (2025-04-22)
26 | -------------------
27 |
28 | - Fix ``PIL.UnidentifiedImageError`` when operating on AVIF images with Pillow 11.2.1 (Storm Heg)
29 | - Pinned ``pillow_heif`` upper version limit to v0.22.0 to ensure AVIF support is available, we will unpin it once we no longer rely on the AVIF support it provides - you may see a warning about AVIF support being deprecated by ``pillow_heif`` in the meantime (Storm Heg)
30 | - Maintenance: run OpenCV testsuite in GitHub Actions (Storm Heg)
31 |
32 | 1.9.0 (2024-10-26)
33 | ------------------
34 |
35 | - Improve type handling when running optimisers (Jake Howard)
36 | - Add support for Pillow 11, Python 3.13 (Storm Heg)
37 | - Drop support for Python 3.8 (Storm Heg)
38 | - Docs: Fix incorrect method name for ``save_as_heic`` in README (Sage Abdullah)
39 | - Docs: Fix link to changelog (Matt Westcott)
40 |
41 | 1.8.0 (2024-01-17)
42 | ------------------
43 |
44 | - Fix sphinx build errors
45 | - Remove old imghdr patch test (Storm Heg)
46 | - Update the OpenCV detect_faces test for determinism (Stephan Lachnit)
47 | - Add ``transform_colorspace_to_srgb`` operation and use it to fix inaccurate colors when saving specific image files (Storm Heg)
48 |
49 | Note: this forces conversion to sRGB for CMYK images with an ICC profile as CMYK is not supported by PNG, WEBP, AVIF and HEIC Pillow encoders.
50 | Otherwise, when a CMYK image is encoded, it gets converted to RGB resulting in inaccurate colors because Pillow ignores the ICC profile when performing the conversion.
51 | So, as a workaround, we manually force an accurate conversion to RGB before encoding the image. This results in a much more accurate representation of the original CMYK image.
52 | - Add support for ICO images (Jake Howard)
53 |
54 | 1.7.0 (2023-11-26)
55 | ------------------
56 |
57 | Note: due to various limitations, version 1.6.3 includes some of the fixes present in 1.7.x, most importantly the
58 | ICC profile and EXIF data when saving a JPEG to PNG, WebP, AVIF.
59 |
60 | - Test with Python 3.12 (@zerolab)
61 | - Add optional dependencies for Pillow/Wand (@zerolab)
62 | One can run ``pip install Willow[Pillow]`` or ``Willow[Wand]`` and get the correct Pillow or Wand versions.
63 | - Replace wrong unicode character in the ``image/heic`` mime type (Stephan Lachnit)
64 | - Fix color management by keeping ICC color profiles and EXIF data in addition (André Fuchs, Stefan Istrate)
65 |
66 | 1.6.3 (2023-11-26)
67 | ------------------
68 |
69 | - Replace wrong unicode character in the ``image/heic`` mime type (Stephan Lachnit)
70 | - Fix color management by keeping ICC color profiles and EXIF data in addition (André Fuchs, Stefan Istrate)
71 |
72 | 1.6.2 (2023-09-06)
73 | ------------------
74 |
75 | - Ensure SVG files are given a mime type (Jake Howard)
76 |
77 |
78 | 1.6.1 (2023-08-04)
79 | ------------------
80 |
81 | - Fix ``NUMBER_PATTERN`` regex for parsing SVG viewboxes (Joshua Munn)
82 |
83 |
84 | 1.6 (2023-07-13)
85 | ----------------
86 |
87 | - Configure linting with black, ruff and pre-commit. Add coverage reports (@zerolab)
88 | - Switch to flit for packaging, and PyPI trusted publishing (@zerolab)
89 | - Drop support for Python 3.7
90 | - Add AVIF support (Aman Pandey)
91 | - Add support for image optimization libraries via :ref:`optimizer classes ` (@zerolab)
92 | - Add check for CMYK when saving as PNG (Stan Mattingly, @zerolab)
93 |
94 |
95 | 1.5.3 (2023-09-06)
96 | ------------------
97 |
98 | - Ensure SVG files are given a mime type (Jake Howard)
99 |
100 |
101 | 1.5.2 (2023-08-04)
102 | ------------------
103 |
104 | - Fix ``NUMBER_PATTERN`` regex for parsing SVG viewboxes (Joshua Munn)
105 |
106 |
107 | 1.5.1 (2023-07-06)
108 | ------------------
109 |
110 | - Fix SVG cropping (Joshua Munn)
111 |
112 |
113 | 1.5 (2023-03-29)
114 | ----------------
115 |
116 | - Drop support for Python versions below 3.7
117 | - Drop support for Pillow versions below 9.1 and fix Pillow 10 deprecation warnings (Alex Tomkins)
118 | - Replace deprecated ``imghdr`` with ``filetype``. This allows detecting newer image formats such as HEIC (Herbert Poul)
119 | - Add SVG support (Joshua Munn)
120 | - Add HEIF support via the ``pillow-heif`` library (Alexander Piskun)
121 |
122 |
123 | 1.4.1 (2022-02-25)
124 | ------------------
125 |
126 | - Drop support for Python 3.4
127 | - Imagemagick 7 compatibility fixes (Matt Westcott)
128 | - Fix: Implemented consistent behavior between Pillow and Wand for out-of-bounds crop rectangles (Matt Westcott)
129 |
130 | 1.4 (2020-05-26)
131 | ----------------
132 |
133 | - Implemented save quality/lossless options for WebP (@mozgsml)
134 | - Added missing docs for WebP support (@mozgsml)
135 |
136 | 1.3 (2019-10-16)
137 | ----------------
138 |
139 | - Added ``.get_frame_count()`` operation (@kaedroho)
140 |
141 | 1.2 (2019-10-11)
142 | ----------------
143 |
144 | - Added WebP support (@frmdstryr)
145 | - Added ``.rotate()`` operation (@mrchrisadams & @simo97)
146 |
147 | 1.1 (2017-12-04)
148 | ----------------
149 |
150 | - Added `set_background_color_rgb` operation
151 | - Update MANIFEST.in (Sanny Kumar)
152 |
153 | 1.0 (2017-08-04)
154 | ----------------
155 |
156 | - OpenCV 3 support (Will Giddens)
157 | - Removed Apple copyrighted ICC profile from orientation test images (Christopher Hoskin)
158 | - Fix: Altered `detect_features` in OpenCV 3 to return a list instead of a numpy array (Trent Holliday)
159 | - Support for TIFF files (Maik Hoepfel)
160 | - Support for BMP files was made official (Maik Hoepfel)
161 |
162 | 0.4 (2016-10-05)
163 | ----------------
164 |
165 | - Support for image optimization and saving progressive JPEG files
166 | - Added documentation
167 |
168 | 0.3.1 (2016-05-16)
169 | ------------------
170 |
171 | - Fixed crash in the Pillow auto_orient operation when the image has an invalid Orientation EXIF Tag (Sigurdur J Eggertsson)
172 | - The ``auto_orient`` operation now catches all errors raised while reading EXIF data (Tomas Olander)
173 | - Palette formatted PNG and GIF files that have transparency no longer lose their transparency when resizing them
174 |
175 | 0.3 (2016-03-09)
176 | ----------------
177 |
178 | A major internals refactor has taken place in this release, there are a number of breaking changes:
179 |
180 | - The Image class is now immutable. Previously, "resize" and "crop" operations altered the image in-place but now they now always return a new image leaving the original untouched.
181 | - There are now multiple Image classes. Each one represents possible state the image can be in (for example in a file, loaded in Pillow, etc). Operations can return an image in a different class to what the operation was performed on.
182 | - The "backends" have been renamed to "plugins".
183 | - A new registry module has been added which can be used for registering new plugins and operations.
184 | - The "original_format" attribute has been deprecated.
185 |
186 | Other changes in this release:
187 |
188 | - Added auto_orient operation
189 |
190 | 0.2.1 (2015-05-27)
191 | ------------------
192 |
193 | - JPEGs are now detected from first two bytes of their file. Allowing non JFIF/EXIF JPEG images to be loaded
194 |
195 | 0.2 (2015-04-01)
196 | ----------------
197 |
198 | - Added loader for BMP files
199 | - Added has_alpha and has_animation operations
200 | - Added get_pillow_image and get_wand_image operations
201 | - Added save_as_{jpeg,png,gif} operations
202 | - Crop and resize now all arguments in a tuple (Similar to Pillow)
203 | - Dropped Python 2.6 and 3.2 support
204 | - Formats now detected using images header instead of extension
205 | - Now possible to specify alternative cascade file for face detection
206 | - Fix: Images now saved in the same format they were loaded
207 | - Fix: 1 and P formatted images now converted to RGB when saving to JPEG
208 |
209 | 0.1 (2015-02-22)
210 | ----------------
211 |
212 | Initial release
213 |
--------------------------------------------------------------------------------
/willow/image.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | from io import BytesIO
4 | from shutil import copyfileobj
5 | from tempfile import NamedTemporaryFile, SpooledTemporaryFile
6 | from typing import Optional
7 |
8 | import filetype
9 | from defusedxml import ElementTree
10 | from filetype.types import image as image_types
11 |
12 | from .registry import registry
13 |
14 |
15 | class UnrecognisedImageFormatError(IOError):
16 | pass
17 |
18 |
19 | class BadImageOperationError(ValueError):
20 | """
21 | Raised when the arguments to an image operation are invalid,
22 | e.g. a crop where the left coordinate is greater than the right coordinate
23 | """
24 |
25 | pass
26 |
27 |
28 | class Image:
29 | @classmethod
30 | def check(cls):
31 | pass
32 |
33 | @staticmethod
34 | def operation(func):
35 | func._willow_operation = True
36 | return func
37 |
38 | @staticmethod
39 | def converter_to(to_class, cost=None):
40 | def wrapper(func):
41 | func._willow_converter_to = (to_class, cost)
42 | return func
43 |
44 | return wrapper
45 |
46 | @staticmethod
47 | def converter_from(from_class, cost=None):
48 | def wrapper(func):
49 | if not hasattr(func, "_willow_converter_from"):
50 | func._willow_converter_from = []
51 |
52 | if isinstance(from_class, list):
53 | func._willow_converter_from.extend([(sc, cost) for sc in from_class])
54 | else:
55 | func._willow_converter_from.append((from_class, cost))
56 |
57 | return func
58 |
59 | return wrapper
60 |
61 | def __getattr__(self, attr):
62 | try:
63 | operation, _, conversion_path, _ = registry.find_operation(type(self), attr)
64 | except LookupError:
65 | # Operation doesn't exist
66 | raise AttributeError(
67 | f"{self.__class__.__name__!r} object has no attribute {attr!r}"
68 | )
69 |
70 | def wrapper(*args, **kwargs):
71 | image = self
72 |
73 | for converter, _ in conversion_path:
74 | image = converter(image)
75 |
76 | return operation(image, *args, **kwargs)
77 |
78 | return wrapper
79 |
80 | # A couple of helpful methods
81 |
82 | @classmethod
83 | def open(cls, f):
84 | # Detect image format
85 | image_format = filetype.guess_extension(f)
86 |
87 | if image_format is None and cls.maybe_xml(f):
88 | image_format = "svg"
89 |
90 | # Find initial class
91 | initial_class = INITIAL_IMAGE_CLASSES.get(image_format)
92 | if not initial_class:
93 | if image_format:
94 | raise UnrecognisedImageFormatError(
95 | f"Cannot load {image_format} images ({INITIAL_IMAGE_CLASSES!r})"
96 | )
97 | else:
98 | raise UnrecognisedImageFormatError("Unknown image format")
99 |
100 | return initial_class(f)
101 |
102 | @classmethod
103 | def maybe_xml(cls, f):
104 | # Check if it looks like an XML doc, it will be validated
105 | # properly when we parse it in SvgImageFile
106 | f.seek(0)
107 | pattern = re.compile(rb"^\s*<")
108 | for line in f:
109 | if pattern.match(line):
110 | f.seek(0)
111 | return True
112 | f.seek(0)
113 | return False
114 |
115 | def save(
116 | self, image_format, output, apply_optimizers=True
117 | ) -> Optional["ImageFile"]:
118 | # Get operation name
119 | if image_format not in [
120 | "jpeg",
121 | "png",
122 | "gif",
123 | "bmp",
124 | "tiff",
125 | "webp",
126 | "svg",
127 | "heic",
128 | "avif",
129 | "ico",
130 | ]:
131 | raise ValueError(f"Unknown image format: {image_format}")
132 |
133 | operation_name = "save_as_" + image_format
134 | return getattr(self, operation_name)(output, apply_optimizers=apply_optimizers)
135 |
136 | def optimize(self, image_file, image_format: str):
137 | """
138 | Runs all available optimizers for the given image format on the given image file.
139 |
140 | If the passed image file is a SpooledTemporaryFile or just bytes, we are converting it to a
141 | NamedTemporaryFile to guarantee we can access the file so the optimizers to work on it.
142 | If we get a string, we assume it's a path to a file, and will attempt to load it from
143 | the file system.
144 | """
145 | optimizers = registry.get_optimizers_for_format(image_format)
146 | if not optimizers:
147 | return
148 |
149 | named_file_created = False
150 | try:
151 | if isinstance(image_file, (SpooledTemporaryFile, BytesIO)):
152 | with NamedTemporaryFile(delete=False) as named_file:
153 | named_file_created = True
154 |
155 | image_file.seek(0)
156 | copyfileobj(image_file, named_file)
157 |
158 | file_path = named_file.name
159 |
160 | elif hasattr(image_file, "name"):
161 | file_path = image_file.name
162 |
163 | elif isinstance(image_file, str):
164 | file_path = image_file
165 |
166 | elif isinstance(image_file, bytes):
167 | with NamedTemporaryFile(delete=False) as named_file:
168 | named_file.write(image_file)
169 | file_path = named_file.name
170 | named_file_created = True
171 |
172 | else:
173 | raise TypeError(
174 | f"Cannot optimise {type(image_file)}. It must be a readable object, or a path to a file"
175 | )
176 |
177 | for optimizer in optimizers:
178 | optimizer.process(file_path)
179 |
180 | if hasattr(image_file, "seek"):
181 | # rewind and replace the image file with the optimized version
182 | image_file.seek(0)
183 | with open(file_path, "rb") as f:
184 | copyfileobj(f, image_file)
185 |
186 | if hasattr(image_file, "truncate"):
187 | image_file.truncate() # bring the file size down to the actual image size
188 |
189 | finally:
190 | if named_file_created:
191 | os.unlink(file_path)
192 |
193 |
194 | class ImageBuffer(Image):
195 | def __init__(self, size, data):
196 | self.size = size
197 | self.data = data
198 |
199 | @Image.operation
200 | def get_size(self):
201 | return self.size
202 |
203 |
204 | class RGBImageBuffer(ImageBuffer):
205 | mode = "RGB"
206 |
207 | @Image.operation
208 | def has_alpha(self):
209 | return False
210 |
211 | @Image.operation
212 | def has_animation(self):
213 | return False
214 |
215 |
216 | class RGBAImageBuffer(ImageBuffer):
217 | mode = "RGBA"
218 |
219 | @Image.operation
220 | def has_alpha(self):
221 | return True
222 |
223 | @Image.operation
224 | def has_animation(self):
225 | return False
226 |
227 |
228 | class ImageFile(Image):
229 | @property
230 | def format_name(self):
231 | """
232 | Willow internal name for the image format
233 | ImageFile implementations MUST override this.
234 | """
235 | raise NotImplementedError
236 |
237 | @property
238 | def mime_type(self):
239 | """
240 | Returns the MIME type of the image file
241 | ImageFile implementations MUST override this.
242 | """
243 | raise NotImplementedError
244 |
245 | def __init__(self, f):
246 | self.f = f
247 |
248 |
249 | class JPEGImageFile(ImageFile):
250 | @property
251 | def format_name(self):
252 | return "jpeg"
253 |
254 | @property
255 | def mime_type(self):
256 | return "image/jpeg"
257 |
258 |
259 | class PNGImageFile(ImageFile):
260 | @property
261 | def format_name(self):
262 | return "png"
263 |
264 | @property
265 | def mime_type(self):
266 | return "image/png"
267 |
268 |
269 | class GIFImageFile(ImageFile):
270 | @property
271 | def format_name(self):
272 | return "gif"
273 |
274 | @property
275 | def mime_type(self):
276 | return "image/gif"
277 |
278 |
279 | class BMPImageFile(ImageFile):
280 | @property
281 | def format_name(self):
282 | return "bmp"
283 |
284 | @property
285 | def mime_type(self):
286 | return "image/bmp"
287 |
288 |
289 | class TIFFImageFile(ImageFile):
290 | @property
291 | def format_name(self):
292 | return "tiff"
293 |
294 | @property
295 | def mime_type(self):
296 | return "image/tiff"
297 |
298 |
299 | class WebPImageFile(ImageFile):
300 | @property
301 | def format_name(self):
302 | return "webp"
303 |
304 | @property
305 | def mime_type(self):
306 | return "image/webp"
307 |
308 |
309 | class SvgImageFile(ImageFile):
310 | format_name = "svg"
311 | mime_type = "image/svg+xml"
312 |
313 | def __init__(self, f, dom=None):
314 | if dom is None:
315 | f.seek(0)
316 | # Will raise xml.etree.ElementTree.ParseError if invalid
317 | self.dom = ElementTree.parse(f)
318 | f.seek(0)
319 | else:
320 | self.dom = dom
321 | super().__init__(f)
322 |
323 |
324 | class HeicImageFile(ImageFile):
325 | @property
326 | def format_name(self):
327 | return "heic"
328 |
329 | @property
330 | def mime_type(self):
331 | return "image/heic"
332 |
333 |
334 | class AvifImageFile(ImageFile):
335 | @property
336 | def format_name(self):
337 | return "avif"
338 |
339 | @property
340 | def mime_type(self):
341 | return "image/avif"
342 |
343 |
344 | class IcoImageFile(ImageFile):
345 | format_name = "ico"
346 | mime_type = "image/x-icon"
347 |
348 |
349 | INITIAL_IMAGE_CLASSES = {
350 | # A mapping of image formats to their initial class
351 | image_types.Jpeg().extension: JPEGImageFile,
352 | image_types.Png().extension: PNGImageFile,
353 | image_types.Gif().extension: GIFImageFile,
354 | image_types.Bmp().extension: BMPImageFile,
355 | image_types.Tiff().extension: TIFFImageFile,
356 | image_types.Webp().extension: WebPImageFile,
357 | "svg": SvgImageFile,
358 | image_types.Heic().extension: HeicImageFile,
359 | image_types.Avif().extension: AvifImageFile,
360 | image_types.Ico().extension: IcoImageFile,
361 | }
362 |
--------------------------------------------------------------------------------
/willow/plugins/wand.py:
--------------------------------------------------------------------------------
1 | import functools
2 | from ctypes import c_char_p, c_void_p
3 |
4 | from willow.image import (
5 | AvifImageFile,
6 | BadImageOperationError,
7 | BMPImageFile,
8 | GIFImageFile,
9 | HeicImageFile,
10 | IcoImageFile,
11 | Image,
12 | JPEGImageFile,
13 | PNGImageFile,
14 | RGBAImageBuffer,
15 | RGBImageBuffer,
16 | TIFFImageFile,
17 | WebPImageFile,
18 | )
19 |
20 |
21 | class UnsupportedRotation(Exception):
22 | pass
23 |
24 |
25 | def _wand_image():
26 | import wand.image
27 |
28 | return wand.image
29 |
30 |
31 | def _wand_color():
32 | import wand.color
33 |
34 | return wand.color
35 |
36 |
37 | def _wand_api():
38 | import wand.api
39 |
40 | return wand.api
41 |
42 |
43 | def _wand_version():
44 | import wand.version
45 |
46 | return wand.version
47 |
48 |
49 | class WandImage(Image):
50 | def __init__(self, image):
51 | self.image = image
52 |
53 | @classmethod
54 | def check(cls):
55 | _wand_image()
56 | _wand_color()
57 | _wand_api()
58 | _wand_version()
59 |
60 | def _clone(self):
61 | return WandImage(self.image.clone())
62 |
63 | @classmethod
64 | def is_format_supported(cls, image_format):
65 | return bool(_wand_version().formats(image_format))
66 |
67 | @Image.operation
68 | def get_size(self):
69 | return self.image.size
70 |
71 | @Image.operation
72 | def get_frame_count(self):
73 | return len(self.image.sequence)
74 |
75 | @Image.operation
76 | def has_alpha(self):
77 | return self.image.alpha_channel
78 |
79 | @Image.operation
80 | def has_animation(self):
81 | return self.image.animation
82 |
83 | @Image.operation
84 | def resize(self, size):
85 | clone = self._clone()
86 | clone.image.resize(size[0], size[1])
87 | return clone
88 |
89 | @Image.operation
90 | def crop(self, rect):
91 | left, top, right, bottom = rect
92 | width, height = self.image.size
93 | if (
94 | left >= right
95 | or left >= width
96 | or right <= 0
97 | or top >= bottom
98 | or top >= height
99 | or bottom <= 0
100 | ):
101 | raise BadImageOperationError(f"Invalid crop dimensions: {rect!r}")
102 |
103 | clone = self._clone()
104 | clone.image.crop(
105 | # clamp to image boundaries
106 | left=max(0, left),
107 | top=max(0, top),
108 | right=min(right, width),
109 | bottom=min(bottom, height),
110 | )
111 | return clone
112 |
113 | @Image.operation
114 | def rotate(self, angle):
115 | not_a_multiple_of_90 = angle % 90
116 |
117 | if not_a_multiple_of_90:
118 | raise UnsupportedRotation(
119 | "Sorry - we only support right angle rotations - i.e. multiples of 90 degrees"
120 | )
121 |
122 | clone = self.image.clone()
123 | clone.rotate(angle)
124 | return WandImage(clone)
125 |
126 | @Image.operation
127 | def set_background_color_rgb(self, color):
128 | if not self.has_alpha():
129 | # Don't change image that doesn't have an alpha channel
130 | return self
131 |
132 | # Check type of color
133 | if not isinstance(color, (tuple, list)) or not len(color) == 3:
134 | raise TypeError("the 'color' argument must be a 3-element tuple or list")
135 |
136 | clone = self._clone()
137 |
138 | # Wand will perform the compositing at the point of setting alpha_channel to 'remove'
139 | clone.image.background_color = _wand_color().Color(
140 | "rgb({}, {}, {})".format(*color)
141 | )
142 | clone.image.alpha_channel = "remove"
143 |
144 | if clone.image.alpha_channel:
145 | # ImageMagick <=6 fails to set alpha_channel to False, so do it manually
146 | clone.image.alpha_channel = False
147 |
148 | return clone
149 |
150 | def get_icc_profile(self):
151 | return self.image.profiles.get("icc")
152 |
153 | def get_exif_data(self):
154 | return self.image.profiles.get("exif")
155 |
156 | @Image.operation
157 | def save_as_jpeg(
158 | self,
159 | f,
160 | quality: int = 85,
161 | progressive: bool = False,
162 | apply_optimizers: bool = True,
163 | **kwargs,
164 | ):
165 | """
166 | Save the image as a JPEG file.
167 |
168 | :param f: the file or file-like object to save to
169 | :param quality: the image quality
170 | :param progressive: whether to save as progressive JPEG file.
171 | :param apply_optimizers: controls whether to run any configured optimizer libraries
172 | :return: JPEGImageFile
173 | """
174 | with self.image.convert("pjpeg" if progressive else "jpeg") as converted:
175 | converted.compression_quality = quality
176 |
177 | icc_profile = self.get_icc_profile()
178 | if icc_profile is not None:
179 | converted.profiles["icc"] = icc_profile
180 |
181 | exif_data = self.get_exif_data()
182 | if exif_data is not None:
183 | converted.profiles["exif"] = exif_data
184 |
185 | converted.save(file=f)
186 |
187 | if apply_optimizers:
188 | self.optimize(f, "jpeg")
189 | return JPEGImageFile(f)
190 |
191 | @Image.operation
192 | def save_as_png(self, f, apply_optimizers: bool = True, **kwargs):
193 | """
194 | Save the image as a PNG file.
195 |
196 | :param f: the file or file-like object to save to
197 | :param apply_optimizers: controls whether to run any configured optimizer libraries
198 | :return: PNGImageFile
199 | """
200 | with self.image.convert("png") as converted:
201 | exif_data = self.get_exif_data()
202 | if exif_data is not None:
203 | converted.profiles["exif"] = exif_data
204 |
205 | converted.save(file=f)
206 |
207 | if apply_optimizers:
208 | self.optimize(f, "png")
209 | return PNGImageFile(f)
210 |
211 | @Image.operation
212 | def save_as_gif(self, f, apply_optimizers: bool = True):
213 | with self.image.convert("gif") as converted:
214 | converted.save(file=f)
215 |
216 | if apply_optimizers:
217 | self.optimize(f, "gif")
218 | return GIFImageFile(f)
219 |
220 | @Image.operation
221 | def save_as_webp(
222 | self,
223 | f,
224 | quality: int = 80,
225 | lossless: bool = False,
226 | apply_optimizers: bool = True,
227 | ):
228 | """
229 | Save the image as a WEBP file.
230 |
231 | :param f: the file or file-like object to save to
232 | :param quality: the image quality
233 | :param lossless: whether to save as lossless WEBP file.
234 | :param apply_optimizers: controls whether to run any configured optimizer libraries.
235 | Note that when lossless=True, this will be ignored.
236 | :return: WebPImageFile
237 | """
238 | with self.image.convert("webp") as converted:
239 | if lossless:
240 | library = _wand_api().library
241 | library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
242 | library.MagickSetOption(
243 | converted.wand,
244 | b"webp:lossless",
245 | b"true",
246 | )
247 | else:
248 | converted.compression_quality = quality
249 |
250 | icc_profile = self.get_icc_profile()
251 | if icc_profile is not None:
252 | converted.profiles["icc"] = icc_profile
253 |
254 | converted.save(file=f)
255 |
256 | if not lossless and apply_optimizers:
257 | self.optimize(f, "webp")
258 | return WebPImageFile(f)
259 |
260 | @Image.operation
261 | def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):
262 | with self.image.convert("avif") as converted:
263 | if lossless:
264 | converted.compression_quality = 100
265 | library = _wand_api().library
266 | library.MagickSetOption.argtypes = [c_void_p, c_char_p, c_char_p]
267 | library.MagickSetOption(
268 | converted.wand,
269 | b"heic:lossless",
270 | b"true",
271 | )
272 | else:
273 | converted.compression_quality = quality
274 | converted.save(file=f)
275 |
276 | if not lossless and apply_optimizers:
277 | self.optimize(f, "avif")
278 |
279 | return AvifImageFile(f)
280 |
281 | @Image.operation
282 | def save_as_ico(self, f, apply_optimizers=True):
283 | with self.image.convert("ico") as converted:
284 | converted.save(file=f)
285 |
286 | if apply_optimizers:
287 | self.optimize(f, "ico")
288 |
289 | return IcoImageFile(f)
290 |
291 | @Image.operation
292 | def auto_orient(self):
293 | image = self.image
294 |
295 | if image.orientation not in ["top_left", "undefined"]:
296 | image = image.clone()
297 | if hasattr(image, "auto_orient"):
298 | # Wand 0.4.1 +
299 | image.auto_orient()
300 | else:
301 | orientation_ops = {
302 | "top_right": [image.flop],
303 | "bottom_right": [functools.partial(image.rotate, degree=180.0)],
304 | "bottom_left": [image.flip],
305 | "left_top": [
306 | image.flip,
307 | functools.partial(image.rotate, degree=90.0),
308 | ],
309 | "right_top": [functools.partial(image.rotate, degree=90.0)],
310 | "right_bottom": [
311 | image.flop,
312 | functools.partial(image.rotate, degree=90.0),
313 | ],
314 | "left_bottom": [functools.partial(image.rotate, degree=270.0)],
315 | }
316 | fns = orientation_ops.get(image.orientation)
317 |
318 | if fns:
319 | for fn in fns:
320 | fn()
321 |
322 | image.orientation = "top_left"
323 |
324 | return WandImage(image)
325 |
326 | @Image.operation
327 | def get_wand_image(self):
328 | return self.image
329 |
330 | @classmethod
331 | @Image.converter_from(JPEGImageFile, cost=150)
332 | @Image.converter_from(PNGImageFile, cost=150)
333 | @Image.converter_from(GIFImageFile, cost=150)
334 | @Image.converter_from(BMPImageFile, cost=150)
335 | @Image.converter_from(TIFFImageFile, cost=150)
336 | @Image.converter_from(WebPImageFile, cost=150)
337 | @Image.converter_from(HeicImageFile, cost=150)
338 | @Image.converter_from(AvifImageFile, cost=150)
339 | @Image.converter_from(IcoImageFile, cost=150)
340 | def open(cls, image_file):
341 | image_file.f.seek(0)
342 | image = _wand_image().Image(file=image_file.f)
343 | image.wand = _wand_api().library.MagickCoalesceImages(image.wand)
344 |
345 | return cls(image)
346 |
347 | @Image.converter_to(RGBImageBuffer)
348 | def to_buffer_rgb(self):
349 | return RGBImageBuffer(self.image.size, self.image.make_blob("RGB"))
350 |
351 | @Image.converter_to(RGBAImageBuffer)
352 | def to_buffer_rgba(self):
353 | return RGBImageBuffer(self.image.size, self.image.make_blob("RGBA"))
354 |
355 |
356 | willow_image_classes = [WandImage]
357 |
--------------------------------------------------------------------------------
/tests/test_image.py:
--------------------------------------------------------------------------------
1 | import io
2 | import os
3 | import unittest
4 | from tempfile import NamedTemporaryFile, SpooledTemporaryFile
5 | from unittest import mock
6 | from xml.etree.ElementTree import ParseError as XMLParseError
7 |
8 | from willow.image import (
9 | AvifImageFile,
10 | BMPImageFile,
11 | GIFImageFile,
12 | HeicImageFile,
13 | IcoImageFile,
14 | Image,
15 | ImageFile,
16 | JPEGImageFile,
17 | PNGImageFile,
18 | SvgImageFile,
19 | TIFFImageFile,
20 | UnrecognisedImageFormatError,
21 | WebPImageFile,
22 | )
23 | from willow.optimizers.base import OptimizerBase
24 | from willow.registry import registry
25 |
26 |
27 | class BrokenImageFileImplementation(ImageFile):
28 | pass
29 |
30 |
31 | class TestImageFile(unittest.TestCase):
32 | def test_image_format_must_be_implemented(self):
33 | broken = BrokenImageFileImplementation(None)
34 | with self.assertRaises(NotImplementedError):
35 | broken.format_name
36 |
37 | def test_mime_type_must_be_implemented(self):
38 | broken = BrokenImageFileImplementation(None)
39 | with self.assertRaises(NotImplementedError):
40 | broken.mime_type
41 |
42 | def test_implementations_have_required_methods(self):
43 | for image_class in ImageFile.__subclasses__():
44 | if image_class == BrokenImageFileImplementation:
45 | continue
46 |
47 | with self.subTest(image_class):
48 | self.assertTrue(hasattr(image_class, "mime_type"))
49 | self.assertTrue(hasattr(image_class, "format_name"))
50 |
51 |
52 | class TestDetectImageFormatFromStream(unittest.TestCase):
53 | """
54 | Tests that Image.open responds correctly to different image headers.
55 |
56 | Note that Image.open is not responsible for verifying image contents so
57 | these tests do not require valid images.
58 | """
59 |
60 | def test_opens_jpeg(self):
61 | f = io.BytesIO()
62 | f.write(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00")
63 | f.seek(0)
64 |
65 | image = Image.open(f)
66 | self.assertIsInstance(image, JPEGImageFile)
67 | self.assertEqual(image.format_name, "jpeg")
68 | self.assertEqual(image.mime_type, "image/jpeg")
69 |
70 | def test_opens_png(self):
71 | f = io.BytesIO()
72 | f.write(b"\x89PNG\x0d\x0a\x1a\x0a")
73 | f.seek(0)
74 |
75 | image = Image.open(f)
76 | self.assertIsInstance(image, PNGImageFile)
77 | self.assertEqual(image.format_name, "png")
78 | self.assertEqual(image.mime_type, "image/png")
79 |
80 | def test_opens_gif(self):
81 | f = io.BytesIO()
82 | f.write(b"GIF89a")
83 | f.seek(0)
84 |
85 | image = Image.open(f)
86 | self.assertIsInstance(image, GIFImageFile)
87 | self.assertEqual(image.format_name, "gif")
88 | self.assertEqual(image.mime_type, "image/gif")
89 |
90 | def test_raises_error_on_invalid_header(self):
91 | f = io.BytesIO()
92 | f.write(b"Not an image")
93 | f.seek(0)
94 |
95 | with self.assertRaises(UnrecognisedImageFormatError):
96 | Image.open(f)
97 |
98 | def test_opens_svg(self):
99 | f = io.BytesIO(b"")
100 | image = Image.open(f)
101 | self.assertIsInstance(image, SvgImageFile)
102 | self.assertEqual(image.format_name, "svg")
103 | self.assertEqual(image.mime_type, "image/svg+xml")
104 |
105 | def test_invalid_svg_raises(self):
106 | f = io.BytesIO(b"