├── 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_meet/translated-x-no-scale-crop-200_0_600_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_meet/translated-x-no-scale-crop-0_0_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/issue-112-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tests/images/svg/results/none/circle-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/issue-112-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/none/circle-scale-2x-crop-125_150_250_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/none/circle-scale-2x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/ratio-match-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/ratio-match-crop-0_200_200_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/none/non_zero_origin/circle-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/negative-translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-y-2x-scale-crop-150_100_450_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-y-2x-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-150_100_450_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/none/non_zero_origin/circle-crop-125_150_250_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/translated-y-no-scale-crop-150_0_450_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-0_200_200_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /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 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_meet/translated-y-no-scale-crop-150_0_450_200.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_meet/translated-y-no-scale-crop-150_200_450_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/originals/none/circle-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/none/circle-scale-2x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_max_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/negative-translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_min_y_mid_meet/translated-x-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/none/non_zero_origin/circle-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/ratio-match-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_slice/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_slice/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_slice/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_slice/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_min_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_slice/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/translated-y-2x-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_max_y_mid_slice/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_max_slice/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_mid_slice/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_min_meet/scaled-down-crop-0_200_400_250.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_min_y_min_meet/scaled-down-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/non_zero_origin/ratio-match-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-200_200_400_400.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/results/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-100_100_300_300.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_max_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_min_meet/translated-y-no-scale-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_min_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_max_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_max_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_max_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_max_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_min_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_min_slice/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_min_y_mid_slice/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_min_y_min_meet/scaled-down-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-y-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/images/svg/originals/x_mid_y_mid_slice/non_zero_origin/scaled-x-crop-original.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 | [![PyPI](https://img.shields.io/pypi/v/Willow.svg)](https://pypi.org/project/Willow/) 4 | [![PyPI downloads](https://img.shields.io/pypi/dm/Willow.svg)](https://pypi.org/project/Willow/) 5 | [![Build Status](https://github.com/torchbox/Willow/workflows/CI/badge.svg)](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"<") 107 | with self.assertRaises(XMLParseError): 108 | Image.open(f) 109 | 110 | 111 | class TestImageFormats(unittest.TestCase): 112 | """ 113 | Tests image formats that are not well covered by the remaining tests. 114 | """ 115 | 116 | def test_jpeg(self): 117 | with open("tests/images/flower.jpg", "rb") as f: 118 | image = Image.open(f) 119 | width, height = image.get_size() 120 | 121 | self.assertIsInstance(image, JPEGImageFile) 122 | self.assertEqual(width, 480) 123 | self.assertEqual(height, 360) 124 | self.assertEqual(image.mime_type, "image/jpeg") 125 | 126 | def test_png(self): 127 | with open("tests/images/transparent.png", "rb") as f: 128 | image = Image.open(f) 129 | width, height = image.get_size() 130 | 131 | self.assertIsInstance(image, PNGImageFile) 132 | self.assertEqual(width, 200) 133 | self.assertEqual(height, 150) 134 | self.assertEqual(image.mime_type, "image/png") 135 | 136 | def test_gif(self): 137 | with open("tests/images/newtons_cradle.gif", "rb") as f: 138 | image = Image.open(f) 139 | width, height = image.get_size() 140 | 141 | self.assertIsInstance(image, GIFImageFile) 142 | self.assertEqual(width, 480) 143 | self.assertEqual(height, 360) 144 | self.assertEqual(image.mime_type, "image/gif") 145 | 146 | def test_bmp(self): 147 | with open("tests/images/sails.bmp", "rb") as f: 148 | image = Image.open(f) 149 | width, height = image.get_size() 150 | 151 | self.assertIsInstance(image, BMPImageFile) 152 | self.assertEqual(width, 768) 153 | self.assertEqual(height, 512) 154 | self.assertEqual(image.mime_type, "image/bmp") 155 | 156 | def test_tiff(self): 157 | with open("tests/images/cameraman.tif", "rb") as f: 158 | image = Image.open(f) 159 | width, height = image.get_size() 160 | 161 | self.assertIsInstance(image, TIFFImageFile) 162 | self.assertEqual(width, 256) 163 | self.assertEqual(height, 256) 164 | self.assertEqual(image.mime_type, "image/tiff") 165 | 166 | def test_webp(self): 167 | with open("tests/images/tree.webp", "rb") as f: 168 | image = Image.open(f) 169 | width, height = image.get_size() 170 | 171 | self.assertIsInstance(image, WebPImageFile) 172 | self.assertEqual(width, 320) 173 | self.assertEqual(height, 241) 174 | self.assertEqual(image.mime_type, "image/webp") 175 | 176 | def test_heic(self): 177 | with open("tests/images/tree.heic", "rb") as f: 178 | image = Image.open(f) 179 | width, height = image.get_size() 180 | 181 | self.assertIsInstance(image, HeicImageFile) 182 | self.assertEqual(width, 320) 183 | self.assertEqual(height, 241) 184 | self.assertEqual(image.mime_type, "image/heic") 185 | 186 | def test_avif(self): 187 | with open("tests/images/tree.avif", "rb") as f: 188 | image = Image.open(f) 189 | width, height = image.get_size() 190 | 191 | self.assertIsInstance(image, AvifImageFile) 192 | self.assertEqual(width, 320) 193 | self.assertEqual(height, 241) 194 | self.assertEqual(image.mime_type, "image/avif") 195 | 196 | def test_ico(self): 197 | with open("tests/images/wagtail.ico", "rb") as f: 198 | image = Image.open(f) 199 | width, height = image.get_size() 200 | 201 | self.assertIsInstance(image, IcoImageFile) 202 | self.assertEqual(width, 48) 203 | self.assertEqual(height, 48) 204 | self.assertEqual(image.mime_type, "image/x-icon") 205 | 206 | 207 | class TestSaveImage(unittest.TestCase): 208 | """ 209 | Image.save must work out the name of the underlying operation based on the 210 | format name and call it. It must not however, allow an invalid image format 211 | name to be passed. 212 | """ 213 | 214 | def test_save_as_jpeg(self): 215 | image = Image() 216 | image.save_as_jpeg = mock.MagicMock() 217 | 218 | image.save("jpeg", "outfile") 219 | image.save_as_jpeg.assert_called_with("outfile", apply_optimizers=True) 220 | 221 | def test_save_as_heic(self): 222 | with open("tests/images/sails.bmp", "rb") as f: 223 | image = Image.open(f) 224 | buf = io.BytesIO() 225 | image.save("heic", buf) 226 | buf.seek(0) 227 | image = Image.open(buf) 228 | self.assertIsInstance(image, HeicImageFile) 229 | self.assertEqual(image.mime_type, "image/heic") 230 | 231 | def test_save_as_avif(self): 232 | with open("tests/images/sails.bmp", "rb") as f: 233 | image = Image.open(f) 234 | buf = io.BytesIO() 235 | image.save("avif", buf) 236 | buf.seek(0) 237 | image = Image.open(buf) 238 | self.assertIsInstance(image, AvifImageFile) 239 | self.assertEqual(image.mime_type, "image/avif") 240 | 241 | def test_save_as_ico(self): 242 | with open("tests/images/sails.bmp", "rb") as f: 243 | image = Image.open(f) 244 | buf = io.BytesIO() 245 | image.save("ico", buf) 246 | buf.seek(0) 247 | image = Image.open(buf) 248 | self.assertIsInstance(image, IcoImageFile) 249 | self.assertEqual(image.mime_type, "image/x-icon") 250 | 251 | def test_save_as_foo(self): 252 | image = Image() 253 | image.save_as_jpeg = mock.MagicMock() 254 | 255 | with self.assertRaises(ValueError): 256 | image.save("foo", "outfile") 257 | 258 | self.assertFalse(image.save_as_jpeg.mock_calls) 259 | 260 | 261 | @mock.patch("willow.optimizers.base.OptimizerBase.process") 262 | class TestOptimizeImage(unittest.TestCase): 263 | class DummyOptimizer(OptimizerBase): 264 | library_name = "dummy" 265 | image_format = "jpeg" 266 | 267 | @classmethod 268 | def check_library(cls) -> bool: 269 | return True 270 | 271 | def setUp(self): 272 | with mock.patch.dict(os.environ, {"WILLOW_OPTIMIZERS": "true"}): 273 | registry.register_optimizer(self.DummyOptimizer) 274 | 275 | self.image = Image() 276 | 277 | def tearDown(self): 278 | # reset the registry as we get the global state 279 | registry._registered_optimizers = [] 280 | 281 | def test_optimize_with_file_path(self, mock_process): 282 | self.image.optimize("outfile", "jpeg") 283 | mock_process.assert_called_with("outfile") 284 | 285 | def test_optimize_with_unrecognised_type(self, mock_process): 286 | with self.assertRaises(TypeError): 287 | self.image.optimize(None, "jpeg") 288 | with self.assertRaises(TypeError): 289 | self.image.optimize(io.StringIO(), "jpeg") 290 | mock_process.assert_not_called() 291 | 292 | @mock.patch("willow.image.NamedTemporaryFile") 293 | @mock.patch("willow.image.os.unlink") 294 | def test_optimize_with_bytes( 295 | self, mock_unlink, mock_named_temporary_file, mock_process 296 | ): 297 | mock_named_temporary_file.return_value.__enter__.return_value.name = "tempfile" 298 | self.image.optimize(b"outfile", "jpeg") 299 | mock_process.assert_called_with("tempfile") 300 | mock_unlink.assert_called_with("tempfile") 301 | 302 | @mock.patch("willow.image.NamedTemporaryFile") 303 | @mock.patch("willow.image.os.unlink") 304 | @mock.patch("builtins.open", mock.mock_open(read_data=b"test")) 305 | def test_optimize_with_spooled_temporary_file( 306 | self, mock_unlink, mock_named_temporary_file, mock_process 307 | ): 308 | mock_named_temporary_file.return_value.__enter__.return_value.name = "tempfile" 309 | with SpooledTemporaryFile() as spooled: 310 | self.image.optimize(spooled, "jpeg") 311 | mock_process.assert_called_with("tempfile") 312 | mock_unlink.assert_called_with("tempfile") 313 | 314 | @mock.patch("builtins.open", mock.mock_open(read_data=b"test")) 315 | def test_optimize_with_named_temporary_file(self, mock_process): 316 | with NamedTemporaryFile() as named_temporary_file: 317 | self.image.optimize(named_temporary_file, "jpeg") 318 | mock_process.assert_called_with(named_temporary_file.name) 319 | 320 | @mock.patch("willow.image.NamedTemporaryFile") 321 | @mock.patch("willow.image.os.unlink") 322 | def test_optimize_with_an_actual_file( 323 | self, mock_unlink, mock_named_temporary_file, mock_process 324 | ): 325 | # We are only interested in opening the actual file, and since optimize will write in place 326 | # let's preserve the original file by mocking the open call with it so we don't end up changing it. 327 | with open("tests/images/people.jpg", "rb") as f: 328 | original_value = f.read() 329 | with ( 330 | open("tests/images/people.jpg", "wb") as f, 331 | mock.patch("builtins.open", mock.mock_open(read_data=original_value)), 332 | ): 333 | self.image.optimize(f, "jpeg") 334 | mock_process.assert_called_with("tests/images/people.jpg") 335 | mock_unlink.assert_not_called() 336 | -------------------------------------------------------------------------------- /willow/svg.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import namedtuple 3 | from copy import copy 4 | from xml.etree.ElementTree import ElementTree 5 | 6 | from .image import BadImageOperationError, Image, SvgImageFile 7 | 8 | 9 | class WillowSvgException(Exception): 10 | pass 11 | 12 | 13 | class InvalidSvgAttribute(WillowSvgException): 14 | pass 15 | 16 | 17 | class InvalidSvgSizeAttribute(WillowSvgException): 18 | pass 19 | 20 | 21 | class SvgViewBoxParseError(WillowSvgException): 22 | pass 23 | 24 | 25 | ViewBox = namedtuple("ViewBox", "min_x min_y width height") 26 | 27 | 28 | def view_box_to_attr_str(view_box): 29 | return f"{view_box.min_x} {view_box.min_y} {view_box.width} {view_box.height}" 30 | 31 | 32 | class ViewportToUserSpaceTransform: 33 | def __init__(self, scale_x, scale_y, translate_x, translate_y): 34 | self.scale_x = scale_x 35 | self.scale_y = scale_y 36 | self.translate_x = translate_x 37 | self.translate_y = translate_y 38 | 39 | def __repr__(self): 40 | return ( 41 | f"{self.__class__.__name__}(scale_x={self.scale_x}, scale_y=" 42 | f"{self.scale_y}, translate_x={self.translate_x}, " 43 | f"translate_y={self.translate_y})" 44 | ) 45 | 46 | def __eq__(self, other): 47 | if not isinstance(other, self.__class__): 48 | return False 49 | return ( 50 | self.scale_x == other.scale_x 51 | and self.scale_y == other.scale_y 52 | and self.translate_x == other.translate_x 53 | and self.translate_y == other.translate_y 54 | ) 55 | 56 | def __call__(self, rect): 57 | left, top, right, bottom = rect 58 | return ( 59 | (left + self.translate_x) / self.scale_x, 60 | (top + self.translate_y) / self.scale_y, 61 | (right + self.translate_x) / self.scale_x, 62 | (bottom + self.translate_y) / self.scale_y, 63 | ) 64 | 65 | 66 | def get_viewport_to_user_space_transform( 67 | svg: "SvgImage", 68 | ) -> ViewportToUserSpaceTransform: 69 | # cairosvg used as a reference 70 | view_box = svg.image.view_box 71 | 72 | preserve_aspect_ratio = svg.image.preserve_aspect_ratio.split() 73 | try: 74 | align, meet_or_slice = preserve_aspect_ratio 75 | except ValueError: 76 | align = preserve_aspect_ratio[0] 77 | meet_or_slice = None 78 | 79 | scale_x = svg.image.width / view_box.width 80 | scale_y = svg.image.height / view_box.height 81 | 82 | if align == "none": 83 | # if align is "none", the viewBox will be scaled non-uniformly, 84 | # so we keep and use both scale_x and scale_y 85 | x_position = "min" 86 | y_position = "min" 87 | else: 88 | x_position = align[1:4].lower() 89 | y_position = align[5:].lower() 90 | choose_coefficient = max if meet_or_slice == "slice" else min 91 | # all values of preserveAspectRatio's `align', other than 92 | # "none", force uniform scaling, so choose the appropriate 93 | # coefficient and use it for scaling both axes 94 | scale_x = scale_y = choose_coefficient(scale_x, scale_y) 95 | 96 | # initial offsets to account for non-zero viewBox min-x and min-y 97 | translate_x = view_box.min_x * scale_x 98 | translate_y = view_box.min_y * scale_y 99 | 100 | # adjust the offsets by the amount the viewBox has been translated 101 | # to fit into the viewport (if any) 102 | if x_position == "mid": 103 | translate_x -= (svg.image.width - view_box.width * scale_x) / 2 104 | elif x_position == "max": 105 | translate_x -= svg.image.width - view_box.width * scale_x 106 | 107 | if y_position == "mid": 108 | translate_y -= (svg.image.height - view_box.height * scale_y) / 2 109 | elif y_position == "max": 110 | translate_y -= svg.image.height - view_box.height * scale_y 111 | 112 | return ViewportToUserSpaceTransform(scale_x, scale_y, translate_x, translate_y) 113 | 114 | 115 | class SvgWrapper: 116 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#length 117 | UNIT_RE = re.compile(r"(?:em|ex|px|in|cm|mm|pt|pc|%)$") 118 | 119 | # https://www.w3.org/TR/SVG11/types.html#DataTypeNumber 120 | # https://www.w3.org/TR/2013/WD-SVG2-20130409/types.html#DataTypeNumber 121 | # This will exclude some inputs that Python will accept (e.g. "1.e9", "1."), 122 | # but for integration with other tools, we should adhere to the spec 123 | NUMBER_PATTERN = r"([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)" 124 | 125 | # https://www.w3.org/Graphics/SVG/1.1/coords.html#ViewBoxAttribute 126 | VIEW_BOX_RE = re.compile( 127 | rf"^{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}(?:,\s*|\s+)" 128 | rf"{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}$" 129 | ) 130 | 131 | PRESERVE_ASPECT_RATIO_RE = re.compile( 132 | r"^none$|^x(Min|Mid|Max)Y(Min|Mid|Max)(\s+(meet|slice))?$", 133 | ) 134 | 135 | # Borrowed from cairosvg 136 | COEFFICIENTS = { 137 | "mm": 1 / 25.4, 138 | "cm": 1 / 2.54, 139 | "in": 1, 140 | "pt": 1 / 72.0, 141 | "pc": 1 / 6.0, 142 | } 143 | 144 | def __init__(self, dom: ElementTree, dpi=96, font_size_px=16): 145 | self.dom = dom 146 | self.dpi = dpi 147 | self.font_size_px = font_size_px 148 | self.view_box = self._get_view_box() 149 | self.preserve_aspect_ratio = self._get_preserve_aspect_ratio() 150 | 151 | width, width_unit = self._get_width() 152 | height, height_unit = self._get_height() 153 | # If one attr is missing or relative, we fall back to the other. After 154 | # this either both will be valid, or neither will, which will be handled 155 | # below. Relative width/height are treated as being undefined - so fall 156 | # back first to the other attribute, then the viewBox, then the browser 157 | # fallback. This gives us some flexibility for real world use cases, where 158 | # SVGs may have a relative height, a relative width, or both 159 | if width is None: 160 | width = height 161 | width_unit = height_unit 162 | elif height is None: 163 | height = width 164 | height_unit = width_unit 165 | elif width_unit == "%": 166 | width = height 167 | width_unit = height_unit 168 | elif height_unit == "%": 169 | height = width 170 | height_unit = width_unit 171 | 172 | # If the root svg element has no width, height, or viewBox attributes, 173 | # emulate browser behaviour and set width and height to 300 and 150 174 | # respectively, and set the viewBox to match 175 | # (https://svgwg.org/specs/integration/#svg-css-sizing). This means we 176 | # can always crop and resize without needing to rasterise 177 | if width is None and height is None or width_unit == "%" and height_unit == "%": 178 | if self.view_box is not None: 179 | self.width = self.view_box.width 180 | self.height = self.view_box.height 181 | else: 182 | self.width = 300 183 | self.height = 150 184 | else: 185 | self.width = self._convert_to_px(width, width_unit) 186 | self.height = self._convert_to_px(height, height_unit) 187 | if self.view_box is None: 188 | self.view_box = ViewBox(0, 0, self.width, self.height) 189 | 190 | def __copy__(self): 191 | # copy() called on ElementTree.Element makes a shallow copy (child 192 | # elements are shared with the original) so is efficient enough - we 193 | # only need to copy the root SVG element, as that is the only element 194 | # we will mutate 195 | dom = ElementTree(copy(self.dom.getroot())) 196 | return self.__class__(dom, dpi=self.dpi, font_size_px=self.font_size_px) 197 | 198 | @classmethod 199 | def from_file(cls, f): 200 | return cls(SvgImageFile(f).dom) 201 | 202 | @property 203 | def root(self): 204 | return self.dom.getroot() 205 | 206 | def _get_preserve_aspect_ratio(self): 207 | value = self.root.get("preserveAspectRatio", "").strip() 208 | if value == "": 209 | return "xMidYMid meet" 210 | if not self.PRESERVE_ASPECT_RATIO_RE.match(value): 211 | raise InvalidSvgAttribute( 212 | f"Unable to parse preserveAspectRatio value '{value}'" 213 | ) 214 | return value 215 | 216 | def _get_width(self): 217 | attr_value = self.root.get("width") 218 | if attr_value: 219 | return self._parse_size(attr_value) 220 | return None, None 221 | 222 | def _get_height(self): 223 | attr_value = self.root.get("height") 224 | if attr_value: 225 | return self._parse_size(attr_value) 226 | return None, None 227 | 228 | def _parse_size(self, raw_value): 229 | clean_value = raw_value.strip() 230 | match = self.UNIT_RE.search(clean_value) 231 | unit = clean_value[match.start() :] if match else None 232 | amount_raw = clean_value[: -len(unit)] if unit else clean_value 233 | try: 234 | amount = float(amount_raw) 235 | except ValueError as err: 236 | raise InvalidSvgSizeAttribute( 237 | f"Unable to parse value from '{raw_value}'" 238 | ) from err 239 | if amount <= 0: 240 | raise InvalidSvgSizeAttribute(f"Negative or 0 sizes are invalid ({amount})") 241 | return amount, unit 242 | 243 | def _convert_to_px(self, size, unit): 244 | if unit in (None, "px"): 245 | return size 246 | elif unit == "em": 247 | return size * self.font_size_px 248 | elif unit == "ex": 249 | # This is not exactly correct, but it's the best we can do 250 | return size * self.font_size_px / 2 251 | else: 252 | return size * self.dpi * self.COEFFICIENTS[unit] 253 | 254 | def _get_view_box(self): 255 | attr_value = self.root.get("viewBox") 256 | if attr_value: 257 | return self._parse_view_box(attr_value) 258 | 259 | @classmethod 260 | def _parse_view_box(cls, raw_value): 261 | match = cls.VIEW_BOX_RE.match(raw_value.strip()) 262 | if match is None: 263 | raise SvgViewBoxParseError(f"Unable to parse viewBox value '{raw_value}'") 264 | return ViewBox(*map(float, match.groups())) 265 | 266 | def set_root_attr(self, attr, value): 267 | self.root.set(attr, str(value)) 268 | 269 | def set_width(self, width): 270 | self.set_root_attr("width", width) 271 | self.width = width 272 | 273 | def set_height(self, height): 274 | self.set_root_attr("height", height) 275 | self.height = height 276 | 277 | def set_view_box(self, view_box): 278 | self.set_root_attr("viewBox", view_box_to_attr_str(view_box)) 279 | self.view_box = view_box 280 | 281 | def write(self, f): 282 | self.dom.write(f, encoding="utf-8") 283 | 284 | 285 | class SvgImage(Image): 286 | def __init__(self, image): 287 | self.image: SvgWrapper = image 288 | 289 | @Image.operation 290 | def crop(self, rect, get_transformer=get_viewport_to_user_space_transform): 291 | left, top, right, bottom = rect 292 | if left >= right or top >= bottom: 293 | raise BadImageOperationError(f"Invalid crop dimensions: {rect}") 294 | 295 | viewport_width = right - left 296 | viewport_height = bottom - top 297 | 298 | transformed_rect = get_transformer(self)(rect) 299 | left, top, right, bottom = transformed_rect 300 | 301 | svg_wrapper = copy(self.image) 302 | view_box_width = right - left 303 | view_box_height = bottom - top 304 | svg_wrapper.set_view_box(ViewBox(left, top, view_box_width, view_box_height)) 305 | svg_wrapper.set_width(viewport_width) 306 | svg_wrapper.set_height(viewport_height) 307 | return self.__class__(image=svg_wrapper) 308 | 309 | @Image.operation 310 | def resize(self, size): 311 | new_width, new_height = size 312 | if new_width < 1 or new_height < 1: 313 | raise BadImageOperationError(f"Invalid resize dimensions: {size}") 314 | 315 | svg_wrapper = copy(self.image) 316 | svg_wrapper.set_width(new_width) 317 | svg_wrapper.set_height(new_height) 318 | return self.__class__(image=svg_wrapper) 319 | 320 | @Image.operation 321 | def get_size(self): 322 | return (self.image.width, self.image.height) 323 | 324 | @Image.operation 325 | def auto_orient(self): 326 | return self 327 | 328 | @Image.operation 329 | def has_animation(self): 330 | return False 331 | 332 | @Image.operation 333 | def get_frame_count(self): 334 | return 1 335 | 336 | def write(self, f): 337 | self.image.write(f) 338 | f.seek(0) 339 | 340 | @Image.operation 341 | def save_as_svg(self, f): 342 | self.write(f) 343 | return SvgImageFile(f, dom=self.image.dom) 344 | 345 | @classmethod 346 | @Image.converter_from(SvgImageFile) 347 | def open(cls, svg_image_file): 348 | return cls(image=SvgWrapper(svg_image_file.dom)) 349 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | 5 | The ``Image`` class 6 | ------------------- 7 | 8 | .. class:: Image 9 | 10 | .. classmethod:: open(file) 11 | 12 | Opens the provided image file detects the format from the image header using 13 | Python's :mod:`filetype` module. 14 | 15 | Returns a subclass of :class:`ImageFile` 16 | 17 | If the image format is unrecognized, this throws a :class:`willow.image.UnrecognisedImageFormatError` 18 | (a subclass of :class:`IOError`) 19 | 20 | .. classmethod:: operation 21 | 22 | A decorator for registering operations. 23 | 24 | The operations will be automatically registered when the image class is registered. 25 | 26 | .. code-block:: python 27 | 28 | from willow.image import Image 29 | 30 | class MyImage(Image): 31 | 32 | @Image.operation 33 | def resize(self, size): 34 | return MyImage(self.image.resize(size)) 35 | 36 | .. classmethod:: converter_from(other_classes, cost=100) 37 | 38 | A decorator for registering a "from" converter, which is a classmethod that 39 | converts an instance of another image class into an instance of this one. 40 | 41 | The ``other_classes`` parameter specifies which classes this converter can 42 | convert from. It can be a single class or a list. 43 | 44 | .. code-block:: python 45 | 46 | from willow.image import Image 47 | 48 | class MyImage(Image): 49 | ... 50 | 51 | @classmethod 52 | @Image.converter_from(JPEGImageFile) 53 | def open_jpeg_file(cls, image_file): 54 | return cls(image=open_jpeg(image_file.f)) 55 | 56 | 57 | It can also be applied multiple times to the same function allowing different 58 | costs to be specified for different classes: 59 | 60 | .. code-block:: python 61 | 62 | @classmethod 63 | @Image.converter_from([JPEGImageFile, PNGImageFile]) 64 | @Image.converter_from(GIFImageFile, cost=200) 65 | def open_file(cls, image_file): 66 | ... 67 | 68 | .. classmethod:: converter_to(other_class, cost=100) 69 | 70 | A decorator for registering a "to" converter, which is a method that converts 71 | this image into an instance of another class. 72 | 73 | The ``other_class`` parameter specifies which class this function converts to. 74 | An individual "to" converter can only convert to a single class. 75 | 76 | .. code-block:: python 77 | 78 | from willow.image import Image 79 | 80 | class MyImage(Image): 81 | ... 82 | 83 | @Image.converter_to(PillowImage) 84 | def convert_to_pillow(self): 85 | image = PIL.Image() # Code to create PIL image object here 86 | return PillowImage(image) 87 | 88 | Builtin operations 89 | ------------------ 90 | 91 | Here's a full list of operations provided by Willow out of the box: 92 | 93 | .. method:: get_size() 94 | 95 | Returns the size of the image as a tuple of two integers: 96 | 97 | .. code-block:: python 98 | 99 | width, height = image.get_size() 100 | 101 | .. method:: get_frame_count() 102 | 103 | Returns the number of frames in an animated image: 104 | 105 | .. code-block:: python 106 | 107 | number_of_frames = image.get_frame_count() 108 | 109 | .. method:: has_alpha 110 | 111 | Returns ``True`` if the image has an alpha channel. 112 | 113 | .. code-block:: python 114 | 115 | if image.has_alpha(): 116 | # Image has alpha 117 | 118 | .. method:: has_animation 119 | 120 | Returns ``True`` if the image is animated. 121 | 122 | .. code-block:: python 123 | 124 | if image.has_animation(): 125 | # Image has animation 126 | 127 | .. method:: resize(size) 128 | 129 | (supported natively for SVG, Pillow/Wand required for others) 130 | 131 | Stretches the image to fit the specified size. Size must be a sequence of two integers: 132 | 133 | .. code-block:: python 134 | 135 | # Resize the image to 100x100 pixels 136 | resized_image = source_image.resize((100, 100)) 137 | 138 | .. method:: crop(region) 139 | 140 | (supported natively for SVG, Pillow/Wand required for others) 141 | 142 | Cuts out the specified region of the image. The region must be a sequence of 143 | four integers (left, top, right, bottom): 144 | 145 | .. code-block:: python 146 | 147 | # Cut out a square from the middle of the image 148 | cropped_image = source_image.resize((100, 100, 200, 200)) 149 | 150 | If the crop rectangle overlaps the image boundaries, it will be reduced to fit within those 151 | boundaries, resulting in an output image smaller than specified. If the crop rectangle is 152 | entirely outside the image, or the coordinates are out of range in any other way (such as 153 | a left coordinate greater than the right coordinate), this throws a 154 | :class:`willow.image.BadImageOperationError` (a subclass of :class:`ValueError`). 155 | 156 | .. method:: set_background_color_rgb(color) 157 | 158 | (Pillow/Wand only) 159 | 160 | If the image has an alpha channel, this will add a background color using 161 | the alpha channel as a mask. The alpha channel will be removed from the 162 | resulting image. 163 | 164 | The background color must be specified as a tuple of three integers with 165 | values between 0 - 255. 166 | 167 | This operation will convert the image to RGB format, but will not do 168 | anything if there is not an alpha channel. 169 | 170 | .. code-block:: python 171 | 172 | # Set the background color of the image to white 173 | image = source_image.set_background_color_rgb((255, 255, 255)) 174 | 175 | .. method:: transform_colorspace_to_srgb(rendering_intent=0) 176 | 177 | (Pillow only) 178 | 179 | Note: This operation has no effect if the image does not have an embedded ICC color profile. 180 | 181 | Transforms the colors of the image to fit inside sRGB color gamut using data 182 | from the embedded ICC profile. The resulting image will always be in RGB format 183 | (or RGBA for images with transparency) and will have a small generic sRGB 184 | ICC profile embedded. 185 | 186 | A large number of devices lack the capability to display images 187 | in color spaces other than sRGB and will automatically squash the colors 188 | to fit inside sRGB gamut. In order to do this accurately, the device uses 189 | the embedded ICC profile. You can use this operation to do the same thing 190 | upfront and save on image size by replacing the (large) embedded profile with a 191 | small generic sRGB profile. Keep in mind that this operation is lossy, devices 192 | that *do* support wider color gamuts, like DCI-P3 or Adobe RGB, will not be 193 | able to display the image in its original colors if the original colors were 194 | outside of sRGB gamut. 195 | 196 | The ``rendering_intent`` parameter specifies the rendering intent to use. 197 | It defaults to 0 (perceptual). This controls how out-of-gamut colors are handled. 198 | 199 | It can be one of the following values: 200 | 201 | * ``0`` - Perceptual (default) 202 | * ``1`` - Relative colorimetric 203 | * ``2`` - Saturation 204 | * ``3`` - Absolute colorimetric 205 | 206 | .. code-block:: python 207 | 208 | image = image.transform_colorspace_to_srgb() 209 | 210 | `Read more about rendering intents on Wikipedia 211 | `_. 212 | 213 | `Read more about color spaces on the web in this WebKit blog post 214 | `_. 215 | 216 | .. method:: auto_orient() 217 | 218 | (Pillow/Wand only) 219 | 220 | Some JPEG files have orientation data in an EXIF tag that needs to be applied 221 | to the image. This method applies this orientation to the image (it is a no-op 222 | for other image formats). 223 | 224 | This should be run before performing any other image operations. 225 | 226 | .. code-block:: python 227 | 228 | image = image.auto_orient() 229 | 230 | .. method:: detect_features() 231 | 232 | (OpenCV only) 233 | 234 | Uses OpenCV to find the most prominent corners in the image. 235 | Useful for detecting interesting features for cropping against. 236 | 237 | Returns a list of two integer tuples containing the coordinates of each 238 | point on the image 239 | 240 | .. code-block:: python 241 | 242 | points = image.detect_features() 243 | 244 | .. method:: detect_faces(cascade_filename) 245 | 246 | (OpenCV only) 247 | 248 | Uses OpenCV's `cascade classification 249 | `_ 250 | to detect faces in the image. 251 | 252 | By default the ``haarcascade_frontalface_alt2.xml`` (provided by OpenCV) 253 | cascade file is used. You can specify the filename to a different cascade 254 | file in the first parameter. 255 | 256 | Returns a list of four integer tuples containing the left, top, right, bottom 257 | locations of each face detected in the image. 258 | 259 | .. code-block:: python 260 | 261 | faces = image.detect_faces() 262 | 263 | .. method:: save_as_jpeg(file, quality=85, optimize=False) 264 | 265 | (Pillow/Wand only) 266 | 267 | Saves the image to the specified file-like object in JPEG format. 268 | 269 | Note: If the image has an alpha channel, this operation may raise an 270 | exception or save a broken image (depending on the backend being used). 271 | To resolve this, use the :meth:`~Image.set_background_color_rgb` method to 272 | replace the alpha channel with a solid background color before saving as JPEG. 273 | 274 | Returns a ``JPEGImageFile`` wrapping the file. 275 | 276 | .. code-block:: python 277 | 278 | with open('out.jpg', 'wb') as f: 279 | image.save_as_jpeg(f) 280 | 281 | .. method:: save_as_png(file, optimize=False) 282 | 283 | (Pillow/Wand only) 284 | 285 | Saves the image to the specified file-like object in PNG format. 286 | 287 | Returns a ``PNGImageFile`` wrapping the file. 288 | 289 | .. code-block:: python 290 | 291 | with open('out.png', 'wb') as f: 292 | image.save_as_png(f) 293 | 294 | .. method:: save_as_gif(file) 295 | 296 | (Pillow/Wand only) 297 | 298 | Saves the image to the specified file-like object in GIF format. 299 | 300 | returns a ``GIFImageFile`` wrapping the file. 301 | 302 | .. code-block:: python 303 | 304 | with open('out.gif', 'wb') as f: 305 | image.save_as_gif(f) 306 | 307 | .. method:: save_as_webp(file, quality=80, lossless=False) 308 | 309 | (Pillow/Wand only) 310 | 311 | Saves the image to the specified file-like object in WEBP format. 312 | 313 | returns a ``WebPImageFile`` wrapping the file. 314 | 315 | .. code-block:: python 316 | 317 | with open('out.webp', 'wb') as f: 318 | image.save_as_webp(f) 319 | 320 | 321 | .. method:: save_as_heic(file, quality=80, lossless=False) 322 | 323 | (Pillow only; requires the `pillow-heif `_ library) 324 | 325 | Saves the image to the specified file-like object in HEIF format. 326 | 327 | returns a ``HeicImageFile`` wrapping the file. 328 | 329 | .. code-block:: python 330 | 331 | with open('out.heic', 'wb') as f: 332 | image.save_as_heic(f) 333 | 334 | 335 | .. method:: save_as_avif(file, quality=80, lossless=False) 336 | 337 | Saves the image to the specified file-like object in AVIF format. 338 | When saving with `lossless=True`, no `chroma subsampling ` is used (4:4:4 instead of the default 4:2:0). 339 | 340 | returns a ``AvifImageFile`` wrapping the file. 341 | 342 | .. code-block:: python 343 | 344 | with open('out.avif', 'wb') as f: 345 | image.save_as_avif(f) 346 | 347 | 348 | .. method:: save_as_svg(file) 349 | 350 | (SVG images only) 351 | 352 | Saves the image to the specified file-like object in SVG format. 353 | 354 | returns a ``SvgImageFile`` wrapping the file. 355 | 356 | .. code-block:: python 357 | 358 | with open('out.svg', 'w') as f: 359 | image.save_as_svg(f) 360 | 361 | 362 | .. method:: save_as_ico(file) 363 | 364 | Saves the image to the specified file-like object in ICO format. 365 | 366 | returns a ``IcoImageFile`` wrapping the file. 367 | 368 | .. code-block:: python 369 | 370 | with open('out.ico', 'w') as f: 371 | image.save_as_ico(f) 372 | 373 | 374 | .. method:: get_pillow_image() 375 | 376 | (Pillow only) 377 | 378 | Returns a ``PIL.Image`` object for the specified image. This may be useful 379 | for reusing existing code that requires a Pillow image. 380 | 381 | .. code-block:: python 382 | 383 | do_thing(image.get_pillow_image()) 384 | 385 | You can convert a ``PIL.Image`` object back into a Willow :class:`Image` 386 | using the ``PillowImage`` class: 387 | 388 | .. code-block:: python 389 | 390 | import PIL.Image 391 | from willow.plugins.pillow import PillowImage 392 | 393 | pillow_image = PIL.Image.open('test.jpg') 394 | image = PillowImage(pillow_image) 395 | 396 | # Now you can use any Willow operation on that image 397 | faces = image.detect_faces() 398 | 399 | .. method:: get_wand_image() 400 | 401 | (Wand only) 402 | 403 | Returns a ``Wand.Image`` object for the specified image. This may be useful 404 | for reusing existing code that requires a Wand image. 405 | 406 | .. code-block:: python 407 | 408 | do_thing(image.get_wand_image()) 409 | 410 | You can convert a ``Wand.Image`` object back into a Willow :class:`Image` 411 | using the ``WandImage`` class: 412 | 413 | .. code-block:: python 414 | 415 | from wand.image import Image 416 | from willow.plugins.wand import WandImage 417 | 418 | # wand_image is an instance of Wand.Image 419 | wand_image = Image(filename='pikachu.png') 420 | image = WandImage(wand_image) 421 | 422 | # Now you can use any Willow operation on that image 423 | faces = image.detect_faces() 424 | --------------------------------------------------------------------------------