├── pilbox ├── test │ ├── __init__.py │ ├── data │ │ ├── test-nonimage.txt │ │ ├── test1.jpg │ │ ├── test2.png │ │ ├── test3.jpg │ │ ├── test4.webp │ │ ├── test5.gif │ │ ├── test6.tif │ │ ├── example.jpg │ │ ├── test space.jpg │ │ ├── test-alpha1.png │ │ ├── test-p-mode.gif │ │ ├── test-advanced.jpg │ │ ├── test-alpha2.webp │ │ ├── test-bad-exif.jpg │ │ ├── test-bad-format.ico │ │ ├── test-orientation.jpg │ │ ├── test-unknown-format │ │ ├── test-incorrect-format.png │ │ └── expected │ │ │ ├── test1-rotate-degree=90.jpg │ │ │ ├── test1-100x200-mode=adapt.jpg │ │ │ ├── test1-100x200-mode=clip.jpg │ │ │ ├── test1-100x200-mode=crop.jpg │ │ │ ├── test1-100x200-mode=fill.jpg │ │ │ ├── test1-100x200-mode=scale.jpg │ │ │ ├── test1-300x300-mode=adapt.jpg │ │ │ ├── test1-300x300-mode=clip.jpg │ │ │ ├── test1-300x300-mode=crop.jpg │ │ │ ├── test1-300x300-mode=fill.jpg │ │ │ ├── test1-300x300-mode=scale.jpg │ │ │ ├── test1-400x300-mode=adapt.jpg │ │ │ ├── test1-400x300-mode=clip.jpg │ │ │ ├── test1-400x300-mode=crop.jpg │ │ │ ├── test1-400x300-mode=fill.jpg │ │ │ ├── test1-400x300-mode=scale.jpg │ │ │ ├── test1-rotate-degree=180.jpg │ │ │ ├── test1-rotate-degree=315.jpg │ │ │ ├── test1-rotate-degree=auto.jpg │ │ │ ├── test2-100x200-mode=adapt.png │ │ │ ├── test2-100x200-mode=clip.png │ │ │ ├── test2-100x200-mode=crop.png │ │ │ ├── test2-100x200-mode=fill.png │ │ │ ├── test2-100x200-mode=scale.png │ │ │ ├── test2-300x300-mode=adapt.png │ │ │ ├── test2-300x300-mode=clip.png │ │ │ ├── test2-300x300-mode=crop.png │ │ │ ├── test2-300x300-mode=fill.png │ │ │ ├── test2-300x300-mode=scale.png │ │ │ ├── test2-400x300-mode=adapt.png │ │ │ ├── test2-400x300-mode=clip.png │ │ │ ├── test2-400x300-mode=crop.png │ │ │ ├── test2-400x300-mode=fill.png │ │ │ ├── test2-400x300-mode=scale.png │ │ │ ├── test2-rotate-degree=auto.png │ │ │ ├── test3-100x200-mode=adapt.jpg │ │ │ ├── test3-100x200-mode=clip.jpg │ │ │ ├── test3-100x200-mode=crop.jpg │ │ │ ├── test3-100x200-mode=fill.jpg │ │ │ ├── test3-100x200-mode=scale.jpg │ │ │ ├── test3-300x300-mode=adapt.jpg │ │ │ ├── test3-300x300-mode=clip.jpg │ │ │ ├── test3-300x300-mode=crop.jpg │ │ │ ├── test3-300x300-mode=fill.jpg │ │ │ ├── test3-300x300-mode=scale.jpg │ │ │ ├── test3-400x300-mode=adapt.jpg │ │ │ ├── test3-400x300-mode=clip.jpg │ │ │ ├── test3-400x300-mode=crop.jpg │ │ │ ├── test3-400x300-mode=fill.jpg │ │ │ ├── test3-400x300-mode=scale.jpg │ │ │ ├── test4-100x200-mode=clip.webp │ │ │ ├── test4-100x200-mode=crop.webp │ │ │ ├── test4-100x200-mode=fill.webp │ │ │ ├── test4-300x300-mode=clip.webp │ │ │ ├── test4-300x300-mode=crop.webp │ │ │ ├── test4-300x300-mode=fill.webp │ │ │ ├── test4-400x300-mode=clip.webp │ │ │ ├── test4-400x300-mode=crop.webp │ │ │ ├── test4-400x300-mode=fill.webp │ │ │ ├── test5-100x200-mode=adapt.gif │ │ │ ├── test5-100x200-mode=clip.gif │ │ │ ├── test5-100x200-mode=crop.gif │ │ │ ├── test5-100x200-mode=fill.gif │ │ │ ├── test5-100x200-mode=scale.gif │ │ │ ├── test5-300x300-mode=adapt.gif │ │ │ ├── test5-300x300-mode=clip.gif │ │ │ ├── test5-300x300-mode=crop.gif │ │ │ ├── test5-300x300-mode=fill.gif │ │ │ ├── test5-300x300-mode=scale.gif │ │ │ ├── test5-400x300-mode=adapt.gif │ │ │ ├── test5-400x300-mode=clip.gif │ │ │ ├── test5-400x300-mode=crop.gif │ │ │ ├── test5-400x300-mode=fill.gif │ │ │ ├── test5-400x300-mode=scale.gif │ │ │ ├── test6-100x200-mode=adapt.tif │ │ │ ├── test6-100x200-mode=clip.tif │ │ │ ├── test6-100x200-mode=crop.tif │ │ │ ├── test6-100x200-mode=fill.tif │ │ │ ├── test6-100x200-mode=scale.tif │ │ │ ├── test6-300x300-mode=adapt.tif │ │ │ ├── test6-300x300-mode=clip.tif │ │ │ ├── test6-300x300-mode=crop.tif │ │ │ ├── test6-300x300-mode=fill.tif │ │ │ ├── test6-300x300-mode=scale.tif │ │ │ ├── test6-400x300-mode=adapt.tif │ │ │ ├── test6-400x300-mode=clip.tif │ │ │ ├── test6-400x300-mode=crop.tif │ │ │ ├── test6-400x300-mode=fill.tif │ │ │ ├── test6-400x300-mode=scale.tif │ │ │ ├── example-500x400-mode=clip.jpg │ │ │ ├── example-500x400-mode=crop.jpg │ │ │ ├── example-500x400-mode=scale.jpg │ │ │ ├── test4-100x200-mode=adapt.webp │ │ │ ├── test4-100x200-mode=scale.webp │ │ │ ├── test4-300x300-mode=adapt.webp │ │ │ ├── test4-300x300-mode=scale.webp │ │ │ ├── test4-400x300-mode=adapt.webp │ │ │ ├── test4-400x300-mode=scale.webp │ │ │ ├── test space-400x300-mode=adapt.jpg │ │ │ ├── test-advanced-125x-mode=adapt.jpg │ │ │ ├── test-advanced-125x-mode=clip.jpg │ │ │ ├── test-advanced-125x-mode=crop.jpg │ │ │ ├── test-advanced-125x-mode=fill.jpg │ │ │ ├── test-advanced-125x-mode=scale.jpg │ │ │ ├── test-advanced-x125-mode=adapt.jpg │ │ │ ├── test-advanced-x125-mode=clip.jpg │ │ │ ├── test-advanced-x125-mode=crop.jpg │ │ │ ├── test-advanced-x125-mode=fill.jpg │ │ │ ├── test-advanced-x125-mode=scale.jpg │ │ │ ├── test-advanced-125x75-mode=crop.gif │ │ │ ├── test-advanced-125x75-mode=crop.jpeg │ │ │ ├── test-advanced-125x75-mode=crop.jpg │ │ │ ├── test-advanced-125x75-mode=crop.png │ │ │ ├── test-advanced-125x75-mode=crop.tiff │ │ │ ├── test-advanced-125x75-mode=crop.webp │ │ │ ├── test-bad-exif-rotate-degree=auto.jpg │ │ │ ├── test-p-mode-100x100-mode=crop.jpeg │ │ │ ├── test1-region-rect=200,175,50,50.jpg │ │ │ ├── test1-rotate-degree=180-expand=1.jpg │ │ │ ├── test1-rotate-degree=315-expand=1.jpg │ │ │ ├── test1-rotate-degree=90-expand=1.jpg │ │ │ ├── test1-region-rect=150,150,100,100.jpg │ │ │ ├── example-500x400-mode=adapt-retain=80.jpg │ │ │ ├── test-orientation-exif-preserve_exif=0.jpg │ │ │ ├── test-orientation-exif-preserve_exif=1.jpg │ │ │ ├── test-orientation-rotate-degree=auto.jpg │ │ │ ├── test1-200x100-mode=crop-position=face.jpg │ │ │ ├── test2-200x100-mode=crop-position=face.png │ │ │ ├── test3-200x100-mode=crop-position=face.jpg │ │ │ ├── test5-200x100-mode=crop-position=face.gif │ │ │ ├── test6-200x100-mode=crop-position=face.tif │ │ │ ├── test1-200x100-mode=crop-position=center.jpg │ │ │ ├── test2-200x100-mode=crop-position=center.png │ │ │ ├── test2-75x125-mode=fill-background=1ccc.png │ │ │ ├── test3-200x100-mode=crop-position=center.jpg │ │ │ ├── test4-200x100-mode=crop-position=face.webp │ │ │ ├── test5-200x100-mode=crop-position=center.gif │ │ │ ├── test6-200x100-mode=crop-position=center.tif │ │ │ ├── example-500x400-mode=fill-background=ccc.jpg │ │ │ ├── test-advanced-125x75-mode=adapt-retain=40.jpg │ │ │ ├── test-advanced-125x75-mode=adapt-retain=60.jpg │ │ │ ├── test-advanced-125x75-mode=adapt-retain=80.jpg │ │ │ ├── test-advanced-125x75-mode=adapt-retain=99.jpg │ │ │ ├── test-advanced-125x75-mode=crop-optimize=1.jpg │ │ │ ├── test-advanced-125x75-mode=crop-quality=50.jpg │ │ │ ├── test-advanced-125x75-mode=crop-quality=75.jpg │ │ │ ├── test-advanced-125x75-mode=crop-quality=90.jpg │ │ │ ├── test2-75x125-mode=fill-background=a0cccccc.png │ │ │ ├── test4-200x100-mode=crop-position=center.webp │ │ │ ├── test-advanced-125x75-mode=crop-position=face.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=left.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=top.jpg │ │ │ ├── test-advanced-125x75-mode=crop-progressive=1.jpg │ │ │ ├── test-advanced-125x75-mode=crop-quality=keep.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=000.gif │ │ │ ├── test-alpha1-125x125-mode=crop-background=000.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=000.png │ │ │ ├── test-alpha1-125x125-mode=crop-background=fff.gif │ │ │ ├── test-alpha1-125x125-mode=crop-background=fff.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=fff.png │ │ │ ├── test-alpha2-125x125-mode=crop-background=000.gif │ │ │ ├── test-alpha2-125x125-mode=crop-background=000.jpg │ │ │ ├── test-alpha2-125x125-mode=crop-background=000.png │ │ │ ├── test-alpha2-125x125-mode=crop-background=fff.gif │ │ │ ├── test-alpha2-125x125-mode=crop-background=fff.jpg │ │ │ ├── test-alpha2-125x125-mode=crop-background=fff.png │ │ │ ├── test1-chained-resize,rotate-150x75-degree=90.jpg │ │ │ ├── test1-chained-resize,rotate-75x150-degree=90.jpg │ │ │ ├── test1-chained-rotate,resize-150x75-degree=90.jpg │ │ │ ├── test1-chained-rotate,resize-75x150-degree=90.jpg │ │ │ ├── test-advanced-125x75-mode=crop-filter=antialias.jpg │ │ │ ├── test-advanced-125x75-mode=crop-filter=bicubic.jpg │ │ │ ├── test-advanced-125x75-mode=crop-filter=bilinear.jpg │ │ │ ├── test-advanced-125x75-mode=crop-filter=nearest.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=bottom.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=center.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=right.jpg │ │ │ ├── test-advanced-125x75-mode=fill-background=F00.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=000.webp │ │ │ ├── test-alpha1-125x125-mode=crop-background=0fff.gif │ │ │ ├── test-alpha1-125x125-mode=crop-background=0fff.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=0fff.png │ │ │ ├── test-alpha1-125x125-mode=crop-background=0fff.webp │ │ │ ├── test-alpha1-125x125-mode=crop-background=fff.webp │ │ │ ├── test-alpha2-125x125-mode=crop-background=000.webp │ │ │ ├── test-alpha2-125x125-mode=crop-background=0fff.gif │ │ │ ├── test-alpha2-125x125-mode=crop-background=0fff.jpg │ │ │ ├── test-alpha2-125x125-mode=crop-background=0fff.png │ │ │ ├── test-alpha2-125x125-mode=crop-background=0fff.webp │ │ │ ├── test-alpha2-125x125-mode=crop-background=fff.webp │ │ │ ├── test-advanced-125x75-mode=crop-position=0.25,0.25.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=0.25,0.75.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=top-left.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=top-right.jpg │ │ │ ├── test-advanced-125x75-mode=fill-background=cccccc.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=a0cccccc.gif │ │ │ ├── test-alpha1-125x125-mode=crop-background=a0cccccc.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=a0cccccc.png │ │ │ ├── test-alpha2-125x125-mode=crop-background=a0cccccc.gif │ │ │ ├── test-alpha2-125x125-mode=crop-background=a0cccccc.jpg │ │ │ ├── test-alpha2-125x125-mode=crop-background=a0cccccc.png │ │ │ ├── example-500x400-mode=adapt-background=ccc-retain=99.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=bottom-left.jpg │ │ │ ├── test-advanced-125x75-mode=crop-position=bottom-right.jpg │ │ │ ├── test-alpha1-125x125-mode=crop-background=a0cccccc.webp │ │ │ ├── test-alpha2-125x125-mode=crop-background=a0cccccc.webp │ │ │ ├── test1-chained-resize,region,rotate-150x75-degree=90-rect=5,5,65,65.jpg │ │ │ ├── test1-chained-resize,region,rotate-75x150-degree=90-rect=5,5,65,65.jpg │ │ │ ├── test1-chained-region,resize,rotate-150x75-degree=90-rect=50,50,150,150.jpg │ │ │ └── test1-chained-region,resize,rotate-75x150-degree=90-rect=50,50,150,150.jpg │ ├── errors_test.py │ ├── signature_test.py │ ├── runtests.py │ ├── genexpected.py │ ├── image_test.py │ └── app_test.py ├── signature.py ├── __init__.py ├── errors.py ├── app.py └── image.py ├── docs ├── index.rst └── conf.py ├── .gitattributes ├── provisioning ├── vagrant ├── files │ └── etc │ │ ├── sudoers │ │ └── init.d │ │ └── pilbox └── playbook.yml ├── MANIFEST.in ├── .coveragerc ├── .gitignore ├── .travis.yml ├── config └── sample ├── Vagrantfile ├── setup.py ├── CHANGES.txt └── LICENSE.txt /pilbox/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pilbox/test/data/test-nonimage.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | pilbox/frontalface.xml linguist-vendored 2 | docs/* linguist-documentation 3 | -------------------------------------------------------------------------------- /provisioning/vagrant: -------------------------------------------------------------------------------- 1 | [vagrant] 2 | 192.168.100.100 3 | 4 | [app] 5 | 192.168.100.100 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include pilbox/frontalface.xml 2 | include README.rst 3 | recursive-exclude pilbox/test *.* 4 | -------------------------------------------------------------------------------- /pilbox/test/data/test1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test2.png -------------------------------------------------------------------------------- /pilbox/test/data/test3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test3.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test4.webp -------------------------------------------------------------------------------- /pilbox/test/data/test5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test5.gif -------------------------------------------------------------------------------- /pilbox/test/data/test6.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test6.tif -------------------------------------------------------------------------------- /pilbox/test/data/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/example.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test space.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test space.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test-alpha1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-alpha1.png -------------------------------------------------------------------------------- /pilbox/test/data/test-p-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-p-mode.gif -------------------------------------------------------------------------------- /pilbox/test/data/test-advanced.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-advanced.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test-alpha2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-alpha2.webp -------------------------------------------------------------------------------- /pilbox/test/data/test-bad-exif.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-bad-exif.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test-bad-format.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-bad-format.ico -------------------------------------------------------------------------------- /pilbox/test/data/test-orientation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-orientation.jpg -------------------------------------------------------------------------------- /pilbox/test/data/test-unknown-format: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-unknown-format -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # This file is automatically generated via sphinx-me 2 | from sphinx_me import setup_conf; setup_conf(globals()) 3 | -------------------------------------------------------------------------------- /pilbox/test/data/test-incorrect-format.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/test-incorrect-format.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-100x200-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-100x200-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-100x200-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-100x200-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-100x200-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-100x200-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-100x200-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-100x200-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-100x200-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-100x200-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-300x300-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-300x300-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-300x300-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-300x300-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-300x300-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-300x300-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-300x300-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-300x300-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-300x300-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-300x300-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-400x300-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-400x300-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-400x300-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-400x300-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-400x300-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-400x300-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-400x300-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-400x300-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-400x300-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-400x300-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=180.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=180.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=315.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=315.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=auto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=auto.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-100x200-mode=adapt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-100x200-mode=adapt.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-100x200-mode=clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-100x200-mode=clip.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-100x200-mode=crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-100x200-mode=crop.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-100x200-mode=fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-100x200-mode=fill.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-100x200-mode=scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-100x200-mode=scale.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-300x300-mode=adapt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-300x300-mode=adapt.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-300x300-mode=clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-300x300-mode=clip.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-300x300-mode=crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-300x300-mode=crop.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-300x300-mode=fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-300x300-mode=fill.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-300x300-mode=scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-300x300-mode=scale.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-400x300-mode=adapt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-400x300-mode=adapt.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-400x300-mode=clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-400x300-mode=clip.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-400x300-mode=crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-400x300-mode=crop.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-400x300-mode=fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-400x300-mode=fill.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-400x300-mode=scale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-400x300-mode=scale.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-rotate-degree=auto.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-rotate-degree=auto.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-100x200-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-100x200-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-100x200-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-100x200-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-100x200-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-100x200-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-100x200-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-100x200-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-100x200-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-100x200-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-300x300-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-300x300-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-300x300-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-300x300-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-300x300-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-300x300-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-300x300-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-300x300-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-300x300-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-300x300-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-400x300-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-400x300-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-400x300-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-400x300-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-400x300-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-400x300-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-400x300-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-400x300-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-400x300-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-400x300-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-100x200-mode=clip.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-100x200-mode=clip.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-100x200-mode=crop.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-100x200-mode=crop.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-100x200-mode=fill.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-100x200-mode=fill.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-300x300-mode=clip.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-300x300-mode=clip.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-300x300-mode=crop.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-300x300-mode=crop.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-300x300-mode=fill.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-300x300-mode=fill.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-400x300-mode=clip.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-400x300-mode=clip.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-400x300-mode=crop.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-400x300-mode=crop.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-400x300-mode=fill.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-400x300-mode=fill.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-100x200-mode=adapt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-100x200-mode=adapt.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-100x200-mode=clip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-100x200-mode=clip.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-100x200-mode=crop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-100x200-mode=crop.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-100x200-mode=fill.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-100x200-mode=fill.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-100x200-mode=scale.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-100x200-mode=scale.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-300x300-mode=adapt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-300x300-mode=adapt.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-300x300-mode=clip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-300x300-mode=clip.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-300x300-mode=crop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-300x300-mode=crop.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-300x300-mode=fill.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-300x300-mode=fill.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-300x300-mode=scale.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-300x300-mode=scale.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-400x300-mode=adapt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-400x300-mode=adapt.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-400x300-mode=clip.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-400x300-mode=clip.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-400x300-mode=crop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-400x300-mode=crop.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-400x300-mode=fill.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-400x300-mode=fill.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-400x300-mode=scale.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-400x300-mode=scale.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-100x200-mode=adapt.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-100x200-mode=adapt.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-100x200-mode=clip.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-100x200-mode=clip.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-100x200-mode=crop.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-100x200-mode=crop.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-100x200-mode=fill.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-100x200-mode=fill.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-100x200-mode=scale.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-100x200-mode=scale.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-300x300-mode=adapt.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-300x300-mode=adapt.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-300x300-mode=clip.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-300x300-mode=clip.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-300x300-mode=crop.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-300x300-mode=crop.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-300x300-mode=fill.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-300x300-mode=fill.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-300x300-mode=scale.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-300x300-mode=scale.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-400x300-mode=adapt.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-400x300-mode=adapt.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-400x300-mode=clip.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-400x300-mode=clip.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-400x300-mode=crop.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-400x300-mode=crop.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-400x300-mode=fill.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-400x300-mode=fill.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-400x300-mode=scale.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-400x300-mode=scale.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-100x200-mode=adapt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-100x200-mode=adapt.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-100x200-mode=scale.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-100x200-mode=scale.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-300x300-mode=adapt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-300x300-mode=adapt.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-300x300-mode=scale.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-300x300-mode=scale.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-400x300-mode=adapt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-400x300-mode=adapt.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-400x300-mode=scale.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-400x300-mode=scale.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test space-400x300-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test space-400x300-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-x125-mode=adapt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-x125-mode=adapt.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-x125-mode=clip.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-x125-mode=clip.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-x125-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-x125-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-x125-mode=fill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-x125-mode=fill.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-x125-mode=scale.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-x125-mode=scale.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.jpeg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.tiff -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-bad-exif-rotate-degree=auto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-bad-exif-rotate-degree=auto.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-p-mode-100x100-mode=crop.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-p-mode-100x100-mode=crop.jpeg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-region-rect=200,175,50,50.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-region-rect=200,175,50,50.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=180-expand=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=180-expand=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=315-expand=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=315-expand=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-rotate-degree=90-expand=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-rotate-degree=90-expand=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-region-rect=150,150,100,100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-region-rect=150,150,100,100.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=adapt-retain=80.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=adapt-retain=80.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-orientation-exif-preserve_exif=0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-orientation-exif-preserve_exif=0.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-orientation-exif-preserve_exif=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-orientation-exif-preserve_exif=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-orientation-rotate-degree=auto.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-orientation-rotate-degree=auto.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-200x100-mode=crop-position=face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-200x100-mode=crop-position=face.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-200x100-mode=crop-position=face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-200x100-mode=crop-position=face.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-200x100-mode=crop-position=face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-200x100-mode=crop-position=face.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-200x100-mode=crop-position=face.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-200x100-mode=crop-position=face.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-200x100-mode=crop-position=face.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-200x100-mode=crop-position=face.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-200x100-mode=crop-position=center.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-200x100-mode=crop-position=center.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-200x100-mode=crop-position=center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-200x100-mode=crop-position=center.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-75x125-mode=fill-background=1ccc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-75x125-mode=fill-background=1ccc.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test3-200x100-mode=crop-position=center.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test3-200x100-mode=crop-position=center.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-200x100-mode=crop-position=face.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-200x100-mode=crop-position=face.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test5-200x100-mode=crop-position=center.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test5-200x100-mode=crop-position=center.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test6-200x100-mode=crop-position=center.tif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test6-200x100-mode=crop-position=center.tif -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=fill-background=ccc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=fill-background=ccc.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=40.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=40.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=60.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=60.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=80.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=80.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=99.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=adapt-retain=99.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-optimize=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-optimize=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=50.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=50.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=75.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=75.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test2-75x125-mode=fill-background=a0cccccc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test2-75x125-mode=fill-background=a0cccccc.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test4-200x100-mode=crop-position=center.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test4-200x100-mode=crop-position=center.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=face.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=left.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=left.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-progressive=1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-progressive=1.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=keep.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-quality=keep.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-resize,rotate-150x75-degree=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-resize,rotate-150x75-degree=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-resize,rotate-75x150-degree=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-resize,rotate-75x150-degree=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-rotate,resize-150x75-degree=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-rotate,resize-150x75-degree=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-rotate,resize-75x150-degree=90.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-rotate,resize-75x150-degree=90.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=antialias.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=antialias.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=bicubic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=bicubic.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=bilinear.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=bilinear.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=nearest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-filter=nearest.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=center.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=center.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=right.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=right.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=fill-background=F00.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=fill-background=F00.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=000.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=0fff.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=fff.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=000.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=0fff.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=fff.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=0.25,0.25.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=0.25,0.25.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=0.25,0.75.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=0.25,0.75.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top-left.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top-left.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top-right.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=top-right.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=fill-background=cccccc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=fill-background=cccccc.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.gif -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.png -------------------------------------------------------------------------------- /pilbox/test/data/expected/example-500x400-mode=adapt-background=ccc-retain=99.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/example-500x400-mode=adapt-background=ccc-retain=99.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom-left.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom-left.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom-right.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-advanced-125x75-mode=crop-position=bottom-right.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha1-125x125-mode=crop-background=a0cccccc.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test-alpha2-125x125-mode=crop-background=a0cccccc.webp -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-resize,region,rotate-150x75-degree=90-rect=5,5,65,65.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-resize,region,rotate-150x75-degree=90-rect=5,5,65,65.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-resize,region,rotate-75x150-degree=90-rect=5,5,65,65.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-resize,region,rotate-75x150-degree=90-rect=5,5,65,65.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-region,resize,rotate-150x75-degree=90-rect=50,50,150,150.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-region,resize,rotate-150x75-degree=90-rect=50,50,150,150.jpg -------------------------------------------------------------------------------- /pilbox/test/data/expected/test1-chained-region,resize,rotate-75x150-degree=90-rect=50,50,150,150.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agschwender/pilbox/HEAD/pilbox/test/data/expected/test1-chained-region,resize,rotate-75x150-degree=90-rect=50,50,150,150.jpg -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = pilbox 4 | omit = pilbox/test/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | def main.app=None.: 10 | def main..: 11 | if __name__ == .__main__.: 12 | except ImportError: 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vagrant files 2 | .vagrant 3 | vagrant_ansible_inventory_default 4 | 5 | # Python 6 | *.pyc 7 | /*.egg-info 8 | /dist/ 9 | 10 | # Coverage 11 | .coverage 12 | htmlcov 13 | 14 | # Build directories 15 | build 16 | 17 | # Virtualenv 18 | /.Python 19 | /bin 20 | /include 21 | /lib 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # http://travis-ci.org/#!/agschwender/pilbox 2 | dist: xenial 3 | sudo: required 4 | language: python 5 | python: 6 | - "2.7_with_system_site_packages" 7 | - 3.6 8 | env: 9 | - PYTHONPATH=$PYTHONPATH:$PWD 10 | before_install: 11 | - sudo apt-get update 12 | - sudo apt-get install libjpeg8-dev libfreetype6-dev zlib1g-dev 13 | - sudo apt-get install libwebp-dev liblcms2-dev 14 | - sudo apt-get install python-numpy python-opencv python-pycurl 15 | install: 16 | - pip install tornado==5.1.0 Pillow==5.2.0 coveralls 17 | - pip install pep8==1.6.2 pyflakes==0.8.1 18 | before_script: 19 | - pep8 --exclude=test pilbox 20 | - pyflakes pilbox/*.py 21 | script: 22 | - coverage run -m pilbox.test.runtests 23 | after_success: 24 | - coveralls 25 | -------------------------------------------------------------------------------- /config/sample: -------------------------------------------------------------------------------- 1 | # General settings 2 | 3 | port = 8888 4 | 5 | # Set client name and key if the application requires signed requests. The 6 | # client must sign the request using the client_key, see README for 7 | # instructions. 8 | 9 | client_name = "sample" 10 | client_key = "3NdajqH8mBLokepU4I2Bh6KK84GUf1lzjnuTdskY" 11 | 12 | # Set the allowed hosts as an alternative to signed requests. Only those images 13 | # which are served from the following hosts will be requested. 14 | 15 | allowed_hosts = ["localhost"] 16 | 17 | # Request related settings 18 | 19 | max_requests = 50 20 | timeout = 7.5 21 | 22 | # Set default resizing options 23 | 24 | background = "ccc" 25 | filter = "bilinear" 26 | format = None 27 | mode = "crop" 28 | position = "top" 29 | quality = "90" 30 | -------------------------------------------------------------------------------- /provisioning/files/etc/sudoers: -------------------------------------------------------------------------------- 1 | # 2 | # This file MUST be edited with the 'visudo' command as root. 3 | # 4 | # Please consider adding local content in /etc/sudoers.d/ instead of 5 | # directly modifying this file. 6 | # 7 | # See the man page for details on how to write a sudoers file. 8 | # 9 | Defaults env_reset 10 | Defaults exempt_group=admin 11 | Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 12 | 13 | # Host alias specification 14 | 15 | # User alias specification 16 | 17 | # Cmnd alias specification 18 | 19 | # User privilege specification 20 | root ALL=(ALL:ALL) ALL 21 | 22 | # Members of the admin group may gain root privileges 23 | %admin ALL=(ALL) NOPASSWD:ALL 24 | 25 | # Allow members of group sudo to execute any command 26 | %sudo ALL=(ALL:ALL) ALL 27 | 28 | # See sudoers(5) for more information on "#include" directives: 29 | 30 | #includedir /etc/sudoers.d 31 | -------------------------------------------------------------------------------- /pilbox/test/errors_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, with_statement 2 | 3 | from tornado.test.util import unittest 4 | 5 | from pilbox.errors import * 6 | 7 | 8 | class ErrorsTest(unittest.TestCase): 9 | 10 | def test_unique_error_codes(self): 11 | errors = [SignatureError, ClientError, HostError, BackgroundError, 12 | DimensionsError, FilterError, FormatError, ModeError, 13 | OptimizeError, PositionError, PreserveExifError, 14 | ProgressiveError, QualityError, UrlError, ImageFormatError, 15 | ImageSaveError, FetchError, DegreeError, OperationError, 16 | RectangleError, RetainError] 17 | codes = [] 18 | for error in errors: 19 | code = str(error.get_code()) 20 | if code in codes: 21 | self.fail("The error code, %s, is repeated" % str(code)) 22 | codes.append(code) 23 | 24 | def test_base_not_implemented(self): 25 | self.assertRaises(NotImplementedError, PilboxError.get_code) 26 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | bootstrap_script = < "192.168.100.100" 24 | 25 | # Share an additional folder to the guest VM. The first argument is 26 | # the path on the host to the actual folder. The second argument is 27 | # the path on the guest to mount the folder. And the optional third 28 | # argument is a set of non-required options. 29 | config.vm.synced_folder ".", "/var/www/pilbox", :owner => 'vagrant' 30 | 31 | # The machine performs its own provisioning. 32 | config.vm.provision 'shell', inline: bootstrap_script, privileged: false 33 | config.vm.provision 'file', { 34 | source: '~/.vagrant.d/insecure_private_key', 35 | destination: '~/.ssh/id_rsa', 36 | } 37 | config.vm.provision 'shell', inline: ansible_script, privileged: false 38 | end 39 | -------------------------------------------------------------------------------- /pilbox/test/signature_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, with_statement 2 | 3 | import hashlib 4 | import hmac 5 | 6 | from tornado.test.util import unittest 7 | 8 | from pilbox.signature import derive_signature, sign, verify_signature 9 | 10 | try: 11 | import urlparse 12 | except ImportError: 13 | import urllib.parse as urlparse 14 | 15 | 16 | class SignatureTest(unittest.TestCase): 17 | def test_derive(self): 18 | key = "abc123" 19 | qs_list = ["x=1&y=2&z=3", "x=%20%2B%2F!%40%23%24%25%5E%26"] 20 | for qs in qs_list: 21 | m = hmac.new(key.encode(), None, hashlib.sha1) 22 | m.update(qs.encode()) 23 | self.assertEqual(derive_signature(key, qs), m.hexdigest()) 24 | 25 | def test_sign(self): 26 | key = "abc123" 27 | qs_list = ["x=1&y=2&z=3", "x=%20%2B%2F!%40%23%24%25%5E%26"] 28 | for qs in qs_list: 29 | o = urlparse.parse_qs(sign(key, qs)) 30 | self.assertTrue("sig" in o) 31 | self.assertTrue(o["sig"]) 32 | 33 | def test_verify(self): 34 | key = "abc123" 35 | qs_list = ["x=1&y=2&z=3", "x=%20%2B%2F!%40%23%24%25%5E%26"] 36 | for qs in qs_list: 37 | self.assertTrue(verify_signature(key, sign(key, qs))) 38 | 39 | def test_bad_signature(self): 40 | key1 = "abc123" 41 | key2 = "def456" 42 | qs_list = ["x=1&y=2&z=3", "x=%20%2B%2F!%40%23%24%25%5E%26"] 43 | for qs in qs_list: 44 | self.assertFalse(verify_signature(key1, sign(key2, qs))) 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from setuptools import setup, Command 4 | 5 | 6 | with open('README.rst') as f: 7 | readme = f.read() 8 | 9 | 10 | class PilboxTest(Command): 11 | user_options=[] 12 | def initialize_options(self): 13 | pass 14 | def finalize_options(self): 15 | pass 16 | def run(self): 17 | import sys,subprocess 18 | errno = subprocess.call( 19 | [sys.executable, os.path.join('pilbox', 'test', 'runtests.py')]) 20 | raise SystemExit(errno) 21 | 22 | 23 | setup(name='pilbox', 24 | version='1.3.4', 25 | description='Pilbox is an image processing application server built on the Tornado web framework using the Pillow Imaging Library', 26 | long_description=readme, 27 | classifiers=[ 28 | 'License :: OSI Approved :: Apache Software License', 29 | 'Programming Language :: Python :: 2', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.6', 33 | ], 34 | url='https://github.com/agschwender/pilbox', 35 | author='Adam Gschwender', 36 | author_email='adam.gschwender@gmail.com', 37 | license='http://www.apache.org/licenses/LICENSE-2.0', 38 | include_package_data=True, 39 | packages=['pilbox'], 40 | package_data={ 41 | 'pilbox': ['frontalface.xml'], 42 | }, 43 | install_requires=[ 44 | 'tornado==5.1.0', 45 | 'Pillow==5.2.0', 46 | 'sphinx-me==0.2.1', 47 | ], 48 | extras_require = { 49 | 'Proxy': ['pycurl'], 50 | 'Facial Recognition': ['cv'] 51 | }, 52 | zip_safe=True, 53 | cmdclass={'test': PilboxTest}, 54 | entry_points = {'console_scripts': ['pilbox = pilbox.app:main']} 55 | ) 56 | -------------------------------------------------------------------------------- /provisioning/files/etc/init.d/pilbox: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: pilbox 4 | # Required-Start: $local_fs $remote_fs $syslog 5 | # Required-Stop: $local_fs $remote_fs $syslog 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: Enables pilbox service 9 | # Description: Enables pilbox service 10 | ### END INIT INFO 11 | 12 | set -e 13 | 14 | # Must be a valid filename 15 | DIR=/var/www/pilbox 16 | NAME=pilbox 17 | PIDFILE=/var/run/$NAME.pid 18 | DAEMON=/usr/bin/env 19 | DAEMON_OPTS="python -m pilbox.app --debug" 20 | 21 | export PATH="${PATH:+$PATH:}/usr/sbin:/sbin" 22 | 23 | case "$1" in 24 | start) 25 | echo -n "Starting daemon: "$NAME 26 | start-stop-daemon --start --quiet --background --chdir $DIR --pidfile $PIDFILE --make-pidfile --exec $DAEMON -- $DAEMON_OPTS 27 | echo "." 28 | ;; 29 | stop) 30 | echo -n "Stopping daemon: "$NAME 31 | start-stop-daemon --stop --quiet --oknodo --pidfile $PIDFILE 32 | rm --preserve-root -f $PIDFILE 33 | echo "." 34 | ;; 35 | restart) 36 | echo -n "Restarting daemon: "$NAME 37 | start-stop-daemon --stop --quiet --oknodo --retry 30 --pidfile $PIDFILE 38 | rm --preserve-root -f $PIDFILE 39 | start-stop-daemon --start --quiet --background --chdir $DIR --pidfile $PIDFILE --make-pidfile --exec $DAEMON -- $DAEMON_OPTS 40 | echo "." 41 | ;; 42 | status) 43 | echo -n "Checking $NAME... " 44 | if [ -f $PIDFILE ]; then 45 | PID=`cat $PIDFILE` 46 | if [ -z "`ps axf | grep ${PID} | grep -v grep`" ]; then 47 | echo "Process dead but pidfile exists" 48 | else 49 | echo "Running" 50 | exit 0 51 | fi 52 | else 53 | echo "Service not running" 54 | fi 55 | exit 1 56 | ;; 57 | *) 58 | echo "Usage: "$1" {start|stop|restart}" 59 | exit 1 60 | esac 61 | 62 | exit 0 63 | -------------------------------------------------------------------------------- /pilbox/signature.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | from __future__ import absolute_import, division, print_function, \ 18 | with_statement 19 | 20 | import hashlib 21 | import hmac 22 | import re 23 | 24 | try: 25 | from urllib import urlencode 26 | except ImportError: 27 | from urllib.parse import urlencode 28 | 29 | try: 30 | import urlparse 31 | except ImportError: 32 | import urllib.parse as urlparse 33 | 34 | 35 | def derive_signature(key, qs): 36 | """Derives the signature from the supplied query string using the key.""" 37 | key, qs = (key or "", qs or "") 38 | return hmac.new(key.encode(), qs.encode(), hashlib.sha1).hexdigest() 39 | 40 | 41 | def sign(key, qs): 42 | """Signs the query string using the key.""" 43 | sig = derive_signature(key, qs) 44 | return "%s&%s" % (qs, urlencode([("sig", sig)])) 45 | 46 | 47 | def verify_signature(key, qs): 48 | """Verifies that the signature in the query string is correct.""" 49 | unsigned_qs = re.sub(r'&?sig=[^&]*', '', qs) 50 | sig = derive_signature(key, unsigned_qs) 51 | return urlparse.parse_qs(qs).get("sig", [None])[0] == sig 52 | 53 | 54 | def main(): 55 | import sys 56 | import tornado.options 57 | from tornado.options import define, options, parse_command_line 58 | define("key", help="the signing key", type=str) 59 | args = parse_command_line() 60 | if not options.key: 61 | tornado.options.print_help() 62 | sys.exit() 63 | 64 | qs = args[0] 65 | if qs and qs[0] == "?": 66 | print("Invalid query string, should not include leading '?'") 67 | sys.exit() 68 | print("Query String: %s" % qs) 69 | print("Signature: %s" % derive_signature(options.key, qs)) 70 | print("Signed Query String: %s" % sign(options.key, qs)) 71 | 72 | 73 | if __name__ == "__main__": 74 | main() 75 | -------------------------------------------------------------------------------- /pilbox/test/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import absolute_import, division, with_statement 4 | 5 | import logging 6 | import sys 7 | import textwrap 8 | 9 | from tornado.test.util import unittest 10 | 11 | TEST_MODULES = [ 12 | 'pilbox.test.app_test', 13 | 'pilbox.test.errors_test', 14 | 'pilbox.test.image_test', 15 | 'pilbox.test.signature_test', 16 | ] 17 | 18 | 19 | def all(): 20 | return unittest.defaultTestLoader.loadTestsFromNames(TEST_MODULES) 21 | 22 | 23 | class PilboxTextTestRunner(unittest.TextTestRunner): 24 | def run(self, test): 25 | result = super(PilboxTextTestRunner, self).run(test) 26 | if result.skipped: 27 | skip_reasons = set(reason for (test, reason) in result.skipped) 28 | self.stream.write(textwrap.fill( 29 | "Some tests were skipped because: %s" % 30 | ", ".join(sorted(skip_reasons)))) 31 | self.stream.write("\n") 32 | return result 33 | 34 | 35 | if __name__ == '__main__': 36 | import warnings 37 | # Be strict about most warnings. This also turns on warnings that are 38 | # ignored by default, including DeprecationWarnings and 39 | # python 3.2's ResourceWarnings. 40 | warnings.filterwarnings("error") 41 | warnings.filterwarnings("ignore", category=ImportWarning) 42 | warnings.filterwarnings("ignore", category=DeprecationWarning) 43 | warnings.filterwarnings("error", category=DeprecationWarning, 44 | module=r"tornado\..*") 45 | # The unittest module is aggressive about deprecating redundant methods, 46 | # leaving some without non-deprecated spellings that work on both 47 | # 2.7 and 3.2 48 | warnings.filterwarnings("ignore", category=DeprecationWarning, 49 | message="Please use assert.* instead") 50 | 51 | logging.getLogger("tornado.access").setLevel(logging.CRITICAL) 52 | 53 | import tornado.testing 54 | kwargs = {} 55 | if sys.version_info >= (3, 2): 56 | # HACK: unittest.main will make its own changes to the warning 57 | # configuration, which may conflict with the settings above 58 | # or command-line flags like -bb. Passing warnings=False 59 | # suppresses this behavior, although this looks like an implementation 60 | # detail. http://bugs.python.org/issue15626 61 | kwargs['warnings'] = False 62 | kwargs['testRunner'] = PilboxTextTestRunner 63 | tornado.testing.main(**kwargs) 64 | -------------------------------------------------------------------------------- /provisioning/playbook.yml: -------------------------------------------------------------------------------- 1 | # Ansible playbook 2 | # 3 | # To provision: 4 | # ansible-playbook -i playbook.yml 5 | # 6 | # VERSION 0.1 7 | # ANSIBLE-VERSION 2.3.0 8 | 9 | - hosts: vagrant 10 | user: vagrant 11 | become: True 12 | 13 | tasks: 14 | - group: name=admin state=present 15 | - user: name=ansible groups=admin shell=/bin/bash password=ansible state=present 16 | - file: path=/etc/sudoers.d mode=0770 owner=root group=root state=directory 17 | - copy: src=files/etc/sudoers dest=/etc/sudoers mode=0440 owner=root group=root 18 | - command: cat /home/vagrant/.ssh/authorized_keys 19 | register: authorized_keys 20 | - authorized_key: user=ansible key="{{ item }}" 21 | with_items: "{{ authorized_keys.stdout_lines }}" 22 | 23 | 24 | - hosts: app 25 | user: ansible 26 | become: True 27 | 28 | tasks: 29 | - name: update apt 30 | apt: update_cache=yes cache_valid_time=3600 31 | 32 | - name: install apt packages 33 | apt: name="{{ item }}" 34 | with_items: 35 | - build-essential 36 | - python 37 | - python-dev 38 | - python-setuptools 39 | - python-pip 40 | - python-numpy 41 | - python-opencv 42 | - python-pycurl 43 | - libjpeg-dev 44 | - libfreetype6-dev 45 | - zlib1g-dev 46 | - libwebp-dev 47 | - liblcms2-dev 48 | 49 | - name: install pip packages 50 | pip: > 51 | name={{ item.name }} 52 | version={{ item.version }} 53 | use_mirrors=yes 54 | with_items: 55 | - { name: 'Pillow', version: '5.2.0' } 56 | - { name: 'tornado', version: '5.1.0' } 57 | - { name: 'coverage', version: '3.6' } 58 | - { name: 'pep8', version: '1.6.2' } 59 | - { name: 'pyflakes', version: '0.8.1' } 60 | - { name: 'pyandoc', version: '0.0.1' } 61 | - { name: 'sphinx-me', version: '0.2.1' } 62 | - { name: 'twine', version: '1.9.1' } 63 | 64 | 65 | - hosts: app 66 | user: ansible 67 | become: True 68 | 69 | tasks: 70 | - name: copy pilbox init script 71 | copy: src=files/etc/init.d/pilbox dest=/etc/init.d/pilbox mode=0755 72 | notify: 73 | - restart pilbox 74 | 75 | - name: install pilbox init script 76 | command: update-rc.d pilbox defaults 77 | 78 | - name: running pilbox check 79 | service: name=pilbox state=started enabled=yes 80 | 81 | handlers: 82 | - name: restart pilbox 83 | service: name=pilbox state=restarted 84 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | * 0.1: Image resizing fit 2 | * 0.1.1: Image cropping 3 | * 0.1.2: Image scaling 4 | * 0.2: Configuration integration 5 | * 0.3: Signature generation 6 | * 0.3.1: Signature command-line tool 7 | * 0.4: Image resize command-line tool 8 | * 0.5: Facial recognition cropping 9 | * 0.6: Fill resizing mode 10 | * 0.7: Resize using crop position 11 | * 0.7.1: Resize using a single dimension, maintaining aspect ratio 12 | * 0.7.2: Added filter and quality options 13 | * 0.7.3: Support python 3 14 | * 0.7.4: Fixed cli for image generation 15 | * 0.7.5: Write output in 16K blocks 16 | * 0.8: Added support for ARGB (alpha-channel) 17 | * 0.8.1: Increased max clients and write block sizes 18 | * 0.8.2: Added configuration for max clients and timeout 19 | * 0.8.3: Only allow http and https protocols 20 | * 0.8.4: Added support for WebP 21 | * 0.8.5: Added format option and configuration overrides for mode and format 22 | * 0.8.6: Added custom position support 23 | * 0.9: Added rotate operation 24 | * 0.9.1: Added sub-region selection operation 25 | * 0.9.4: Added Pilbox as a PyPI package 26 | * 0.9.10: Converted README to reStructuredText 27 | * 0.9.14: Added Sphinx docs 28 | * 0.9.15: Added implicit base url 29 | * 0.9.16: Added validate cert to configuration 30 | * 0.9.17: Added support for GIF format 31 | * 0.9.18: Fix for travis builds on python 2.6 and 3.3 32 | * 0.9.19: Validate cert fix 33 | * 0.9.20: Added optimize option 34 | * 0.9.21: Added console script entry point 35 | * 1.0.0: Modified for easier library usage 36 | * 1.0.1: Added allowed operations and default operation 37 | * 1.0.2: Modified to allow override of http content type 38 | * 1.0.3: Safely catch image save errors 39 | * 1.0.4: Added progressive option 40 | * 1.1.0: Proxy server support 41 | * 1.1.1: Added JPEG auto rotation based on Exif orientation 42 | * 1.1.2: Added keep JPEG quality option and set JPEG subsampling to keep 43 | * 1.1.3: Fix auto rotation on JPEG with missing Exif data 44 | * 1.1.4: Exception handling around invalid Exif data 45 | * 1.1.5: Fixed image requests without content types 46 | * 1.1.6: Support custom applications that need command line arguments 47 | * 1.1.7: Support adapt resize mode 48 | * 1.1.8: Added preserve Exif flag 49 | * 1.1.9: Increased Pillow version to 2.8.1 50 | * 1.1.10: Added ca_certs option 51 | * 1.1.11: Added support for TIFF 52 | * 1.2.0: Support setting background when saving a transparent image 53 | - *Backwards incompatible*: default background property changed to `0fff`. To restore previous behavior, set background in config to `ffff`. 54 | * 1.2.1: Added max operations config property 55 | * 1.2.2: Added max resize width and height config properties 56 | * 1.2.3: Added user_agent option 57 | * 1.3.0: Increased Pillow to 4.1.0 and Tornado to 4.5.1 58 | * 1.3.1: Fix pilbox.image CLI for python 3.0 59 | * 1.3.2: Fix GIF P-mode to JPEG conversion 60 | * 1.3.3: Increase Pillow version to 5.2.0 and Tornado version to 5.1.0 61 | * 1.3.4: Added worker config property to set number of Tornado processes 62 | -------------------------------------------------------------------------------- /pilbox/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | """ 18 | Pilbox server and tools 19 | 20 | Versions: 21 | 22 | * 0.1: Image resizing fit 23 | * 0.1.1: Image cropping 24 | * 0.1.2: Image scaling 25 | * 0.2: Configuration integration 26 | * 0.3: Signature generation 27 | * 0.3.1: Signature command-line tool 28 | * 0.4: Image resize command-line tool 29 | * 0.5: Facial recognition cropping 30 | * 0.6: Fill resizing mode 31 | * 0.7: Resize using crop position 32 | * 0.7.1: Resize using a single dimension, maintaining aspect ratio 33 | * 0.7.2: Added filter and quality options 34 | * 0.7.3: Support python 3 35 | * 0.7.4: Fixed cli for image generation 36 | * 0.7.5: Write output in 16K blocks 37 | * 0.8: Added support for ARGB (alpha-channel) 38 | * 0.8.1: Increased max clients and write block sizes 39 | * 0.8.2: Added configuration for max clients and timeout 40 | * 0.8.3: Only allow http and https protocols 41 | * 0.8.4: Added support for WebP 42 | * 0.8.5: Added format option and configuration for mode and format 43 | * 0.8.6: Added custom position support 44 | * 0.9: Added rotate operation 45 | * 0.9.1: Added sub-region selection operation 46 | * 0.9.4: Added Pilbox as a PyPI package 47 | * 0.9.10: Converted README to reStructuredText 48 | * 0.9.14: Added Sphinx docs 49 | * 0.9.15: Added implicit base url 50 | * 0.9.16: Added validate cert to configuration 51 | * 0.9.17: Added support for GIF format 52 | * 0.9.18: Fix for travis builds on python 2.6 and 3.3 53 | * 0.9.19: Validate cert fix 54 | * 0.9.20: Added optimize option 55 | * 0.9.21: Added console script entry point 56 | * 1.0.0: Modified for easier library usage 57 | * 1.0.1: Added allowed operations and default operation 58 | * 1.0.2: Modified to allow override of http content type 59 | * 1.0.3: Safely catch image save errors 60 | * 1.0.4: Added progressive option 61 | * 1.1.0: Proxy server support 62 | * 1.1.1: Added JPEG auto rotation based on Exif orientation 63 | * 1.1.2: Added keep JPEG quality option and set JPEG subsampling to keep 64 | * 1.1.3: Fixed auto rotation on JPEG with missing Exif data 65 | * 1.1.4: Exception handling around invalid Exif data 66 | * 1.1.5: Fixed image requests without content types 67 | * 1.1.6: Support custom applications that need command line arguments 68 | * 1.1.7: Support adapt resize mode 69 | * 1.1.8: Added preserve Exif flag 70 | * 1.1.9: Increased Pillow version to 2.8.1 71 | * 1.1.10: Added ca_certs option 72 | * 1.1.11: Added support for TIFF 73 | * 1.2.0: Support setting background when saving a transparent image 74 | * *Backwards incompatible*: default background property changed to 75 | `0fff`. To restore previous behavior, set background in config 76 | to `ffff`. 77 | * 1.2.1: Added max operations config property 78 | * 1.2.2: Added max resize width and height config properties 79 | * 1.2.3: Added user_agent option 80 | * 1.3.0: Increased Pillow to 2.9.0 and Tornado to 4.5.1 81 | * 1.3.1: Fix pilbox.image CLI for python 3.0 82 | * 1.3.2: Fix GIF P-mode to JPEG conversion 83 | * 1.3.3: Increase Pillow version to 5.2.0 and Tornado version to 5.1.0 84 | * 1.3.4: Added worker config property to set number of Tornado processes 85 | """ 86 | 87 | # human-readable version number 88 | version = "1.3.4" 89 | 90 | # The first three numbers are the components of the version number. 91 | # The fourth is zero for an official release, positive for a development 92 | # branch, or negative for a release candidate or beta (after the base version 93 | # number has been incremented) 94 | version_info = (1, 3, 4, 0) 95 | -------------------------------------------------------------------------------- /pilbox/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | from __future__ import absolute_import, division, with_statement 18 | 19 | import tornado.web 20 | 21 | 22 | class PilboxError(tornado.web.HTTPError): 23 | @staticmethod 24 | def get_code(): 25 | raise NotImplementedError() 26 | 27 | 28 | class BadRequestError(PilboxError): 29 | def __init__(self, msg=None, *args, **kwargs): 30 | super(BadRequestError, self).__init__(400, msg, *args, **kwargs) 31 | 32 | 33 | class BackgroundError(BadRequestError): 34 | @staticmethod 35 | def get_code(): 36 | return 1 37 | 38 | 39 | class DimensionsError(BadRequestError): 40 | @staticmethod 41 | def get_code(): 42 | return 2 43 | 44 | 45 | class FilterError(BadRequestError): 46 | @staticmethod 47 | def get_code(): 48 | return 3 49 | 50 | 51 | class FormatError(BadRequestError): 52 | @staticmethod 53 | def get_code(): 54 | return 4 55 | 56 | 57 | class ModeError(BadRequestError): 58 | @staticmethod 59 | def get_code(): 60 | return 5 61 | 62 | 63 | class PositionError(BadRequestError): 64 | @staticmethod 65 | def get_code(): 66 | return 6 67 | 68 | 69 | class QualityError(BadRequestError): 70 | @staticmethod 71 | def get_code(): 72 | return 7 73 | 74 | 75 | class UrlError(BadRequestError): 76 | @staticmethod 77 | def get_code(): 78 | return 8 79 | 80 | 81 | class DegreeError(BadRequestError): 82 | @staticmethod 83 | def get_code(): 84 | return 9 85 | 86 | 87 | class OperationError(BadRequestError): 88 | @staticmethod 89 | def get_code(): 90 | return 10 91 | 92 | 93 | class RectangleError(BadRequestError): 94 | @staticmethod 95 | def get_code(): 96 | return 11 97 | 98 | 99 | class OptimizeError(BadRequestError): 100 | @staticmethod 101 | def get_code(): 102 | return 12 103 | 104 | 105 | class PreserveExifError(BadRequestError): 106 | @staticmethod 107 | def get_code(): 108 | return 15 109 | 110 | 111 | class ProgressiveError(BadRequestError): 112 | @staticmethod 113 | def get_code(): 114 | return 13 115 | 116 | 117 | class RetainError(BadRequestError): 118 | @staticmethod 119 | def get_code(): 120 | return 14 121 | 122 | 123 | class FetchError(PilboxError): 124 | def __init__(self, msg=None, *args, **kwargs): 125 | super(FetchError, self).__init__(404, msg, *args, **kwargs) 126 | 127 | @staticmethod 128 | def get_code(): 129 | return 301 130 | 131 | 132 | class ForbiddenError(PilboxError): 133 | def __init__(self, msg=None, *args, **kwargs): 134 | super(ForbiddenError, self).__init__(403, msg, *args, **kwargs) 135 | 136 | 137 | class SignatureError(ForbiddenError): 138 | @staticmethod 139 | def get_code(): 140 | return 101 141 | 142 | 143 | class ClientError(ForbiddenError): 144 | @staticmethod 145 | def get_code(): 146 | return 102 147 | 148 | 149 | class HostError(ForbiddenError): 150 | @staticmethod 151 | def get_code(): 152 | return 103 153 | 154 | 155 | class UnsupportedError(PilboxError): 156 | def __init__(self, msg=None, *args, **kwargs): 157 | super(UnsupportedError, self).__init__(415, msg, *args, **kwargs) 158 | 159 | 160 | class ImageFormatError(UnsupportedError): 161 | @staticmethod 162 | def get_code(): 163 | return 201 164 | 165 | 166 | class ImageSaveError(UnsupportedError): 167 | @staticmethod 168 | def get_code(): 169 | return 202 170 | -------------------------------------------------------------------------------- /pilbox/test/genexpected.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | import logging 18 | import sys 19 | import textwrap 20 | 21 | from . import image_test 22 | from ..image import Image 23 | 24 | logging.basicConfig() 25 | 26 | 27 | def main(): 28 | """Generates expected results using the current application libraries. This 29 | is a convenience program that is intended to regenerate the tests after an 30 | algorithm or mode change that would alter the expected output.""" 31 | 32 | warning = "WARNING: All expected tests will be regenerated, output must" \ 33 | " be verified to ensure future tests are producing accurate results." 34 | print "\n".join(textwrap.wrap(warning)) + "\n" 35 | proceed = raw_input("Are you sure you want to proceed? [y/N] ") 36 | if proceed not in ["y", "Y"]: 37 | print "Not proceeding, done" 38 | sys.exit() 39 | 40 | cases = image_test.get_image_resize_cases() 41 | for case in cases: 42 | with open(case["source_path"], "rb") as f: 43 | 44 | print "Generating %s" % case["expected_path"] 45 | img = Image(f).resize( 46 | case["width"], case["height"], mode=case["mode"], 47 | background=case.get("background"), filter=case.get("filter"), 48 | position=case.get("position"), retain=case.get("retain")) 49 | rv = img.save( 50 | format=case.get("format"), 51 | optimize=case.get("optimize"), 52 | background=case.get("background"), 53 | progressive=case.get("progressive"), 54 | quality=case.get("quality")) 55 | 56 | with open(case["expected_path"], "wb") as expected: 57 | expected.write(rv.read()) 58 | 59 | cases = image_test.get_image_rotate_cases() 60 | for case in cases: 61 | with open(case["source_path"], "rb") as f: 62 | 63 | print "Generating %s" % case["expected_path"] 64 | img = Image(f).rotate( 65 | case["degree"], expand=case.get("expand"), 66 | filter=case.get("filter")) 67 | rv = img.save( 68 | format=case.get("format"), 69 | optimize=case.get("optimize"), 70 | progressive=case.get("progressive"), 71 | quality=case.get("quality")) 72 | 73 | with open(case["expected_path"], "wb") as expected: 74 | expected.write(rv.read()) 75 | 76 | cases = image_test.get_image_region_cases() 77 | for case in cases: 78 | with open(case["source_path"], "rb") as f: 79 | 80 | print "Generating %s" % case["expected_path"] 81 | img = Image(f).region(case["rect"].split(",")) 82 | rv = img.save( 83 | format=case.get("format"), 84 | optimize=case.get("optimize"), 85 | progressive=case.get("progressive"), 86 | quality=case.get("quality")) 87 | 88 | with open(case["expected_path"], "wb") as expected: 89 | expected.write(rv.read()) 90 | 91 | cases = image_test.get_image_chained_cases() 92 | for case in cases: 93 | with open(case["source_path"], "rb") as f: 94 | 95 | print "Generating %s" % case["expected_path"] 96 | img = Image(f) 97 | for operation in case["operation"]: 98 | if operation == "resize": 99 | img.resize(case["width"], case["height"]) 100 | elif operation == "rotate": 101 | img.rotate(case["degree"]) 102 | elif operation == "region": 103 | img.region(case["rect"].split(",")) 104 | 105 | rv = img.save() 106 | with open(case["expected_path"], "wb") as expected: 107 | expected.write(rv.read()) 108 | 109 | cases = image_test.get_image_exif_cases() 110 | for case in cases: 111 | with open(case["source_path"], "rb") as f: 112 | 113 | print "Generating %s" % case["expected_path"] 114 | img = Image(f).resize(case["width"], case["height"]) 115 | rv = img.save(preserve_exif=case['preserve_exif']) 116 | 117 | with open(case["expected_path"], "wb") as expected: 118 | expected.write(rv.read()) 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | -------------------------------------------------------------------------------- /pilbox/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | from __future__ import absolute_import, division, with_statement 18 | 19 | import logging 20 | import socket 21 | 22 | import tornado.escape 23 | import tornado.gen 24 | import tornado.httpclient 25 | import tornado.httpserver 26 | import tornado.ioloop 27 | import tornado.options 28 | import tornado.web 29 | from tornado.options import define, options, parse_config_file 30 | 31 | from pilbox import errors 32 | from pilbox.image import Image 33 | from pilbox.signature import verify_signature 34 | 35 | try: 36 | from urlparse import urlparse, urljoin 37 | except ImportError: 38 | from urllib.parse import urlparse, urljoin 39 | 40 | try: 41 | import pycurl 42 | except ImportError: 43 | pycurl = None 44 | 45 | 46 | # general settings 47 | define("config", help="path to configuration file", 48 | callback=lambda path: parse_config_file(path, final=False)) 49 | define("debug", help="run in debug mode", type=bool, default=False) 50 | define("port", help="run on the given port", type=int, default=8888) 51 | define("workers", help="number of worker processes (0 = auto)", 52 | type=int, default=0) 53 | 54 | # security related settings 55 | define("client_name", help="client name") 56 | define("client_key", help="client key") 57 | define("allowed_hosts", help="valid hosts", default=[], multiple=True) 58 | define("allowed_operations", help="valid ops", default=[], multiple=True) 59 | define("max_operations", help="maximum operations to perform", default=10) 60 | define("max_resize_height", help="maximum resize height", default=15000) 61 | define("max_resize_width", help="maximum resize width", default=15000) 62 | 63 | # request related settings 64 | define("max_requests", help="max concurrent requests", type=int, default=40) 65 | define("timeout", help="request timeout in seconds", type=float, default=10) 66 | define("implicit_base_url", help="prepend protocol/host to url paths") 67 | define("ca_certs", 68 | help="override filename of CA certificates in PEM format", 69 | default=None) 70 | define("validate_cert", help="validate certificates", type=bool, default=True) 71 | define("proxy_host", help="proxy hostname") 72 | define("proxy_port", help="proxy port", type=int) 73 | define("user_agent", help="user agent", type=str) 74 | 75 | # header related settings 76 | define("content_type_from_image", 77 | help="override content type using image mime type", 78 | type=bool) 79 | 80 | # default image option settings 81 | define("background", help="default hexadecimal bg color (RGB or ARGB)") 82 | define("expand", help="default to expand when rotating", type=int) 83 | define("filter", help="default filter to use when resizing") 84 | define("format", help="default format to use when outputting") 85 | define("mode", help="default mode to use when resizing") 86 | define("operation", help="default operation to perform") 87 | define("optimize", help="default to optimize when saving", type=int) 88 | define("position", help="default cropping position") 89 | define("progressive", help="default to progressive when saving", type=int) 90 | define("quality", help="default jpeg quality, 1-99 or keep") 91 | define("retain", help="default adaptive retain percent, 1-99", type=int) 92 | define("preserve_exif", help="default behavior for exif data", type=int) 93 | 94 | logger = logging.getLogger("tornado.application") 95 | 96 | 97 | class PilboxApplication(tornado.web.Application): 98 | 99 | def __init__(self, **kwargs): 100 | settings = dict( 101 | debug=options.debug, 102 | client_name=options.client_name, 103 | client_key=options.client_key, 104 | allowed_hosts=options.allowed_hosts, 105 | allowed_operations=set( 106 | options.allowed_operations or ImageHandler.OPERATIONS), 107 | max_operations=options.max_operations, 108 | max_resize_height=options.max_resize_height, 109 | max_resize_width=options.max_resize_width, 110 | background=options.background, 111 | expand=options.expand, 112 | filter=options.filter, 113 | format=options.format, 114 | mode=options.mode, 115 | operation=options.operation, 116 | optimize=options.optimize, 117 | position=options.position, 118 | progressive=options.progressive, 119 | quality=options.quality, 120 | max_requests=options.max_requests, 121 | timeout=options.timeout, 122 | implicit_base_url=options.implicit_base_url, 123 | ca_certs=options.ca_certs, 124 | user_agent=options.user_agent, 125 | validate_cert=options.validate_cert, 126 | content_type_from_image=options.content_type_from_image, 127 | proxy_host=options.proxy_host, 128 | proxy_port=options.proxy_port, 129 | preserve_exif=options.preserve_exif) 130 | 131 | settings.update(kwargs) 132 | 133 | if settings.get("proxy_host") and pycurl is None: # pragma: no cover 134 | raise Exception("PycURL is required for proxy requests") 135 | 136 | if pycurl is not None: # pragma: no cover 137 | tornado.httpclient.AsyncHTTPClient.configure( 138 | "tornado.curl_httpclient.CurlAsyncHTTPClient") 139 | 140 | tornado.web.Application.__init__(self, self.get_handlers(), **settings) 141 | 142 | def get_handlers(self): 143 | return [(r"/", ImageHandler)] 144 | 145 | 146 | class ImageHandler(tornado.web.RequestHandler): 147 | FORWARD_HEADERS = ["Cache-Control", "Expires", "Last-Modified"] 148 | OPERATIONS = ["region", "resize", "rotate", "noop"] 149 | 150 | _FORMAT_TO_MIME = { 151 | "gif": "image/gif", 152 | "jpeg": "image/jpeg", 153 | "jpg": "image/jpeg", 154 | "png": "image/png", 155 | "webp": "image/webp", 156 | "tiff": "image/tiff", 157 | } 158 | 159 | @tornado.gen.coroutine 160 | def get(self): 161 | self.validate_request() 162 | resp = yield self.fetch_image() 163 | self.render_image(resp) 164 | 165 | def get_argument(self, name, default=None, strip=True): 166 | return super(ImageHandler, self).get_argument(name, default, strip) 167 | 168 | def validate_request(self): 169 | self._validate_operation() 170 | self._validate_url() 171 | self._validate_signature() 172 | self._validate_client() 173 | self._validate_host() 174 | 175 | opts = self._get_save_options() 176 | ops = self._get_operations() 177 | if "resize" in ops: 178 | w, h = self.get_argument("w"), self.get_argument("h") 179 | Image.validate_dimensions(w, h) 180 | if w and int(w) > self.settings.get("max_resize_width"): 181 | raise errors.DimensionsError("Exceeds maximum allowed width") 182 | elif h and int(h) > self.settings.get("max_resize_height"): 183 | raise errors.DimensionsError("Exceeds maximum allowed height") 184 | opts.update(self._get_resize_options()) 185 | if "rotate" in ops: 186 | Image.validate_degree(self.get_argument("deg")) 187 | opts.update(self._get_rotate_options()) 188 | if "region" in ops: 189 | Image.validate_rectangle(self.get_argument("rect")) 190 | 191 | Image.validate_options(opts) 192 | 193 | @tornado.gen.coroutine 194 | def fetch_image(self): 195 | url = self.get_argument("url") 196 | if self.settings.get("implicit_base_url") \ 197 | and urlparse(url).hostname is None: 198 | url = urljoin(self.settings.get("implicit_base_url"), url) 199 | 200 | client = tornado.httpclient.AsyncHTTPClient( 201 | max_clients=self.settings.get("max_requests")) 202 | try: 203 | resp = yield client.fetch( 204 | url, 205 | request_timeout=self.settings.get("timeout"), 206 | ca_certs=self.settings.get("ca_certs"), 207 | validate_cert=self.settings.get("validate_cert"), 208 | user_agent=self.settings.get("user_agent"), 209 | proxy_host=self.settings.get("proxy_host"), 210 | proxy_port=self.settings.get("proxy_port")) 211 | raise tornado.gen.Return(resp) 212 | except (socket.gaierror, tornado.httpclient.HTTPError) as e: 213 | logger.warn("Fetch error for %s: %s", 214 | self.get_argument("url"), 215 | str(e)) 216 | raise errors.FetchError() 217 | 218 | def render_image(self, resp): 219 | outfile, outfile_format = self._process_response(resp) 220 | self._set_headers(resp.headers, outfile_format) 221 | for block in iter(lambda: outfile.read(65536), b""): 222 | self.write(block) 223 | outfile.close() 224 | 225 | def write_error(self, status_code, **kwargs): 226 | err = kwargs["exc_info"][1] if "exc_info" in kwargs else None 227 | if isinstance(err, errors.PilboxError): 228 | self.set_header("Content-Type", "application/json") 229 | resp = dict(status_code=status_code, 230 | error_code=err.get_code(), 231 | error=err.log_message) 232 | self.finish(tornado.escape.json_encode(resp)) 233 | else: 234 | super(ImageHandler, self).write_error(status_code, **kwargs) 235 | 236 | def _process_response(self, resp): 237 | ops = self._get_operations() 238 | if "noop" in ops: 239 | return (resp.buffer, None) 240 | 241 | image = Image(resp.buffer) 242 | for operation in ops: 243 | if operation == "resize": 244 | self._image_resize(image) 245 | elif operation == "rotate": 246 | self._image_rotate(image) 247 | elif operation == "region": 248 | self._image_region(image) 249 | 250 | return (self._image_save(image), image.img.format) 251 | 252 | def _image_region(self, image): 253 | image.region(self.get_argument("rect").split(",")) 254 | 255 | def _image_resize(self, image): 256 | opts = self._get_resize_options() 257 | image.resize(self.get_argument("w"), self.get_argument("h"), **opts) 258 | 259 | def _image_rotate(self, image): 260 | opts = self._get_rotate_options() 261 | image.rotate(self.get_argument("deg"), **opts) 262 | 263 | def _image_save(self, image): 264 | opts = self._get_save_options() 265 | return image.save(**opts) 266 | 267 | def _set_headers(self, headers, file_format): 268 | if file_format and any((self.get_argument("fmt"), 269 | self.settings.get("format"), 270 | self.settings.get("content_type_from_image"))): 271 | self.set_header( 272 | "Content-Type", self._FORMAT_TO_MIME.get(file_format.lower())) 273 | elif "Content-Type" in headers: 274 | self.set_header("Content-Type", headers["Content-Type"]) 275 | 276 | for k in ImageHandler.FORWARD_HEADERS: 277 | if k in headers and headers[k]: 278 | self.set_header(k, headers[k]) 279 | 280 | def _get_operations(self): 281 | return self.get_argument( 282 | "op", self.settings.get("operation") or "resize").split(",") 283 | 284 | def _get_resize_options(self): 285 | return self._get_options( 286 | dict(mode=self.get_argument("mode"), 287 | filter=self.get_argument("filter"), 288 | position=self.get_argument("pos"), 289 | background=self.get_argument("bg"), 290 | retain=self.get_argument("retain"))) 291 | 292 | def _get_rotate_options(self): 293 | return self._get_options( 294 | dict(expand=self.get_argument("expand"))) 295 | 296 | def _get_save_options(self): 297 | return self._get_options( 298 | dict(format=self.get_argument("fmt"), 299 | optimize=self.get_argument("opt"), 300 | quality=self.get_argument("q"), 301 | progressive=self.get_argument("prog"), 302 | background=self.get_argument("bg"), 303 | preserve_exif=self.get_argument("exif"))) 304 | 305 | def _get_options(self, opts): 306 | for k, v in opts.items(): 307 | if v is None: 308 | opts[k] = self.settings.get(k, None) 309 | return opts 310 | 311 | def _validate_operation(self): 312 | operations = set(self._get_operations()) 313 | if not operations.issubset(self.settings.get("allowed_operations")): 314 | raise errors.OperationError("Unsupported operation") 315 | elif len(operations) > self.settings.get("max_operations"): 316 | raise errors.OperationError("Too many operations") 317 | 318 | def _validate_url(self): 319 | url = self.get_argument("url") 320 | if not url: 321 | raise errors.UrlError("Missing url") 322 | elif url.startswith("http://") or url.startswith("https://"): 323 | return 324 | elif self.settings.get("implicit_base_url") and url.startswith("/"): 325 | return 326 | raise errors.UrlError("Unsupported protocol") 327 | 328 | def _validate_client(self): 329 | client = self.settings.get("client_name") 330 | if client and self.get_argument("client") != client: 331 | raise errors.ClientError("Invalid client") 332 | 333 | def _validate_signature(self): 334 | key = self.settings.get("client_key") 335 | if key and not verify_signature(key, urlparse(self.request.uri).query): 336 | raise errors.SignatureError("Invalid signature") 337 | 338 | def _validate_host(self): 339 | hosts = self.settings.get("allowed_hosts", []) 340 | if hosts and urlparse(self.get_argument("url")).hostname not in hosts: 341 | raise errors.HostError("Invalid host") 342 | 343 | 344 | def parse_command_line(): # pragma: no cover 345 | tornado.options.parse_command_line() 346 | 347 | 348 | def start_server(app=None): # pragma: no cover 349 | if options.debug: 350 | logger.setLevel(logging.DEBUG) 351 | server = tornado.httpserver.HTTPServer( 352 | app if app else PilboxApplication()) 353 | logger.info("Starting server...") 354 | try: 355 | server.bind(options.port) 356 | server.start(1 if options.debug else options.workers) 357 | tornado.ioloop.IOLoop.instance().start() 358 | except KeyboardInterrupt: 359 | tornado.ioloop.IOLoop.instance().stop() 360 | 361 | 362 | def main(app=None): 363 | parse_command_line() 364 | start_server(app) 365 | 366 | 367 | if __name__ == "__main__": 368 | main() 369 | -------------------------------------------------------------------------------- /pilbox/image.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2013 Adam Gschwender 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | from __future__ import absolute_import, division, print_function, \ 18 | with_statement 19 | 20 | import logging 21 | import re 22 | import os.path 23 | 24 | import PIL.Image 25 | import PIL.ImageOps 26 | 27 | from pilbox import errors 28 | 29 | try: 30 | from io import BytesIO 31 | except ImportError: 32 | from cStringIO import StringIO as BytesIO 33 | 34 | try: 35 | import cv 36 | except ImportError: 37 | cv = None 38 | 39 | logger = logging.getLogger("tornado.application") 40 | 41 | _positions_to_ratios = { 42 | "top-left": (0.0, 0.0), "top": (0.5, 0.0), "top-right": (1.0, 0.0), 43 | "left": (0.0, 0.5), "center": (0.5, 0.5), "right": (1.0, 0.5), 44 | "bottom-left": (0.0, 1.0), "bottom": (0.5, 1.0), 45 | "bottom-right": (1.0, 1.0), "face": None 46 | } 47 | 48 | _orientation_to_rotation = { 49 | 3: 180, 50 | 6: 90, 51 | 8: 270 52 | } 53 | 54 | _filters_to_pil = { 55 | "antialias": PIL.Image.ANTIALIAS, 56 | "bicubic": PIL.Image.BICUBIC, 57 | "bilinear": PIL.Image.BILINEAR, 58 | "nearest": PIL.Image.NEAREST 59 | } 60 | 61 | _formats_to_pil = { 62 | "gif": "GIF", 63 | "jpg": "JPEG", 64 | "jpeg": "JPEG", 65 | "png": "PNG", 66 | "webp": "WEBP", 67 | "tiff": "TIFF" 68 | } 69 | 70 | 71 | class Image(object): 72 | FILTERS = _filters_to_pil.keys() 73 | FORMATS = _formats_to_pil.keys() 74 | MODES = ["adapt", "clip", "crop", "fill", "scale"] 75 | POSITIONS = _positions_to_ratios.keys() 76 | 77 | _DEFAULTS = dict(background="0fff", expand=False, filter="antialias", 78 | format=None, mode="crop", optimize=False, 79 | position="center", quality=90, progressive=False, 80 | retain=75, preserve_exif=False) 81 | _CLASSIFIER_PATH = os.path.join( 82 | os.path.dirname(__file__), "frontalface.xml") 83 | 84 | def __init__(self, stream): 85 | self.stream = stream 86 | self._skip_background = False 87 | try: 88 | self.img = PIL.Image.open(self.stream) 89 | except IOError: 90 | raise errors.ImageFormatError("File is not an image") 91 | 92 | # Cache original Exif data, since it can be erased by some operations 93 | self._exif = self.img.info.get('exif', b'') 94 | 95 | if self.img.format.lower() not in self.FORMATS: 96 | raise errors.ImageFormatError( 97 | "Unknown format: %s" % self.img.format) 98 | self._orig_format = self.img.format 99 | 100 | @staticmethod 101 | def validate_dimensions(width, height): 102 | if not width and not height: 103 | raise errors.DimensionsError("Missing dimensions") 104 | elif width and not str(width).isdigit(): 105 | raise errors.DimensionsError("Invalid width: %s" % width) 106 | elif height and not str(height).isdigit(): 107 | raise errors.DimensionsError("Invalid height: %s" % height) 108 | 109 | @staticmethod 110 | def validate_degree(deg): 111 | if deg is None or deg == "": 112 | raise errors.DegreeError("Missing degree") 113 | elif deg == "auto": 114 | return 115 | elif not Image._isint(deg): 116 | raise errors.DegreeError("Invalid degree: %s" % deg) 117 | elif int(deg) < 0 or int(deg) >= 360: 118 | raise errors.DegreeError("Invalid degree: %s" % deg) 119 | 120 | @staticmethod 121 | def validate_rectangle(rect): 122 | if not rect: 123 | raise errors.RectangleError("Missing rectangle") 124 | rect = rect.split(",") 125 | if len(rect) != 4: 126 | raise errors.RectangleError("Invalid rectangle") 127 | for a in rect: 128 | if not Image._isint(a): 129 | raise errors.RectangleError("Invalid rectangle") 130 | elif int(a) < 0: 131 | raise errors.RectangleError("Region out-of-bounds") 132 | 133 | @staticmethod 134 | def validate_options(opts): 135 | opts = Image._normalize_options(opts) 136 | if opts["mode"] not in Image.MODES: 137 | raise errors.ModeError("Invalid mode: %s" % opts["mode"]) 138 | elif opts["filter"] not in Image.FILTERS: 139 | raise errors.FilterError("Invalid filter: %s" % opts["filter"]) 140 | elif opts["format"] and opts["format"] not in Image.FORMATS: 141 | raise errors.FormatError("Invalid format: %s" % opts["format"]) 142 | elif opts["position"] not in Image.POSITIONS \ 143 | and not opts["pil"]["position"]: 144 | raise errors.PositionError( 145 | "Invalid position: %s" % opts["position"]) 146 | elif not Image._isint(opts["background"], 16) \ 147 | or len(opts["background"]) not in [3, 4, 6, 8]: 148 | raise errors.BackgroundError( 149 | "Invalid background: %s" % opts["background"]) 150 | elif opts["optimize"] and not Image._isint(opts["optimize"]): 151 | raise errors.OptimizeError( 152 | "Invalid optimize: %s", str(opts["optimize"])) 153 | elif opts["quality"] != "keep" and \ 154 | (not Image._isint(opts["quality"]) or 155 | int(opts["quality"]) > 100 or 156 | int(opts["quality"]) < 0): 157 | raise errors.QualityError( 158 | "Invalid quality: %s", str(opts["quality"])) 159 | elif opts["preserve_exif"] and not Image._isint(opts["preserve_exif"]): 160 | raise errors.PreserveExifError( 161 | "Invalid preserve_exif: %s" % str(opts["preserve_exif"])) 162 | elif opts["progressive"] and not Image._isint(opts["progressive"]): 163 | raise errors.ProgressiveError( 164 | "Invalid progressive: %s", str(opts["progressive"])) 165 | elif (not Image._isint(opts["retain"]) or 166 | int(opts["retain"]) > 100 or 167 | int(opts["retain"]) < 0): 168 | raise errors.RetainError( 169 | "Invalid retain: %s" % str(opts["retain"])) 170 | 171 | def region(self, rect): 172 | """ Selects a sub-region of the image using the supplied rectangle, 173 | x, y, width, height. 174 | """ 175 | box = (int(rect[0]), int(rect[1]), int(rect[0]) + int(rect[2]), 176 | int(rect[1]) + int(rect[3])) 177 | if box[2] > self.img.size[0] or box[3] > self.img.size[1]: 178 | raise errors.RectangleError("Region out-of-bounds") 179 | self.img = self.img.crop(box) 180 | return self 181 | 182 | def resize(self, width, height, **kwargs): 183 | """Resizes the image to the supplied width/height. Returns the 184 | instance. Supports the following optional keyword arguments: 185 | 186 | mode - The resizing mode to use, see Image.MODES 187 | filter - The filter to use: see Image.FILTERS 188 | background - The hexadecimal background fill color, RGB or ARGB 189 | position - The position used to crop: see Image.POSITIONS for 190 | pre-defined positions or a custom position ratio 191 | retain - The minimum percentage of the original image to retain 192 | when cropping 193 | """ 194 | opts = Image._normalize_options(kwargs) 195 | size = self._get_size(width, height) 196 | if opts["mode"] == "adapt": 197 | self._adapt(size, opts) 198 | elif opts["mode"] == "clip": 199 | self._clip(size, opts) 200 | elif opts["mode"] == "fill": 201 | self._fill(size, opts) 202 | elif opts["mode"] == "scale": 203 | self._scale(size, opts) 204 | else: 205 | self._crop(size, opts) 206 | return self 207 | 208 | def rotate(self, deg, **kwargs): 209 | """ Rotates the image clockwise around its center. Returns the 210 | instance. Supports the following optional keyword arguments: 211 | 212 | expand - Expand the output image to fit rotation 213 | """ 214 | opts = Image._normalize_options(kwargs) 215 | 216 | if deg == "auto": 217 | if self._orig_format == "JPEG": 218 | try: 219 | exif = self.img._getexif() or dict() 220 | deg = _orientation_to_rotation.get(exif.get(274, 0), 0) 221 | except Exception: 222 | logger.warn('unable to parse exif') 223 | deg = 0 224 | else: 225 | deg = 0 226 | 227 | deg = 360 - (int(deg) % 360) 228 | if deg % 90 == 0: 229 | if deg == 90: 230 | self.img = self.img.transpose(PIL.Image.ROTATE_90) 231 | elif deg == 180: 232 | self.img = self.img.transpose(PIL.Image.ROTATE_180) 233 | elif deg == 270: 234 | self.img = self.img.transpose(PIL.Image.ROTATE_270) 235 | else: 236 | self.img = self.img.rotate(deg, expand=bool(int(opts["expand"]))) 237 | 238 | return self 239 | 240 | def save(self, **kwargs): 241 | """Returns a buffer to the image for saving, supports the 242 | following optional keyword arguments: 243 | 244 | format - The format to save as: see Image.FORMATS 245 | optimize - The image file size should be optimized 246 | preserve_exif - Preserve the Exif information in JPEGs 247 | progressive - The output should be progressive JPEG 248 | quality - The quality used to save JPEGs: integer from 1 - 100 249 | """ 250 | opts = Image._normalize_options(kwargs) 251 | outfile = BytesIO() 252 | if opts["pil"]["format"]: 253 | fmt = opts["pil"]["format"] 254 | else: 255 | fmt = self._orig_format 256 | save_kwargs = dict() 257 | 258 | if Image._isint(opts["quality"]): 259 | save_kwargs["quality"] = int(opts["quality"]) 260 | 261 | if int(opts["optimize"]): 262 | save_kwargs["optimize"] = True 263 | 264 | if int(opts["progressive"]): 265 | save_kwargs["progressive"] = True 266 | 267 | if int(opts["preserve_exif"]): 268 | save_kwargs["exif"] = self._exif 269 | 270 | color = color_hex_to_dec_tuple(opts["background"]) 271 | 272 | if self.img.mode == "RGBA": 273 | self._background(fmt, color) 274 | 275 | if fmt == "JPEG": 276 | if self.img.mode == "P": 277 | # Converting old GIF and PNG files to JPEG can raise 278 | # IOError: cannot write mode P as JPEG 279 | # https://mail.python.org/pipermail/python-list/2000-May/036017.html 280 | self.img = self.img.convert("RGB") 281 | elif self.img.mode == "RGBA": 282 | # JPEG does not have an alpha channel so cannot be 283 | # saved as RGBA. It must be converted to RGB. 284 | self.img = self.img.convert("RGB") 285 | 286 | if self._orig_format == "JPEG": 287 | self.img.format = self._orig_format 288 | save_kwargs["subsampling"] = "keep" 289 | if opts["quality"] == "keep": 290 | save_kwargs["quality"] = "keep" 291 | 292 | try: 293 | self.img.save(outfile, fmt, **save_kwargs) 294 | except IOError as e: 295 | raise errors.ImageSaveError(str(e)) 296 | self.img.format = fmt 297 | outfile.seek(0) 298 | 299 | return outfile 300 | 301 | def _adapt(self, size, opts): 302 | source_aspect_ratio = float(self.img.size[0]) / float(self.img.size[1]) 303 | aspect_ratio = float(size[0]) / float(size[1]) 304 | if source_aspect_ratio >= aspect_ratio: 305 | retain = (aspect_ratio / source_aspect_ratio) * 100.0 306 | else: 307 | retain = (source_aspect_ratio / aspect_ratio) * 100.0 308 | 309 | if float(opts["retain"]) <= retain: 310 | self._crop(size, opts) 311 | else: 312 | self._fill(size, opts) 313 | 314 | def _clip(self, size, opts): 315 | self.img.thumbnail(size, opts["pil"]["filter"]) 316 | 317 | def _background(self, fmt, color): 318 | if self._skip_background: 319 | return 320 | img = PIL.Image.new(mode="RGBA", size=self.img.size, color=color) 321 | if self.img.mode == "RGBA" and Image._supports_alpha(fmt): 322 | self.img = PIL.Image.alpha_composite(img, self.img) 323 | else: 324 | bands = self.img.split() 325 | mask = bands[3] if len(bands) == 4 else None 326 | img.paste(self.img, mask=mask) 327 | self.img = img 328 | 329 | def _crop(self, size, opts): 330 | if opts["position"] == "face": 331 | if cv is None: 332 | raise NotImplementedError 333 | else: 334 | pos = self._get_face_position() 335 | else: 336 | pos = opts["pil"]["position"] 337 | self.img = PIL.ImageOps.fit( 338 | self.img, size, opts["pil"]["filter"], 0, pos) 339 | 340 | def _fill(self, size, opts): 341 | self._clip(size, opts) 342 | if self.img.size == size: 343 | return # No need to fill 344 | x = max(int((size[0] - self.img.size[0]) / 2.0), 0) 345 | y = max(int((size[1] - self.img.size[1]) / 2.0), 0) 346 | color = color_hex_to_dec_tuple(opts["background"]) 347 | mode = "RGBA" if len(color) == 4 else "RGB" 348 | img = PIL.Image.new(mode=mode, size=size, color=color) 349 | # If the image has an alpha channel, use it as a mask when 350 | # pasting onto the background. 351 | channels = self.img.split() 352 | mask = channels[3] if len(channels) == 4 else None 353 | img.paste(self.img, (x, y), mask=mask) 354 | self._skip_background = True 355 | self.img = img 356 | 357 | def _scale(self, size, opts): 358 | self.img = self.img.resize(size, opts["pil"]["filter"]) 359 | 360 | def _get_size(self, width, height): 361 | aspect_ratio = self.img.size[0] / self.img.size[1] 362 | if not width: 363 | width = int((int(height) or self.img.size[1]) * aspect_ratio) 364 | if not height: 365 | height = int((int(width) or self.img.size[0]) / aspect_ratio) 366 | return (int(width), int(height)) 367 | 368 | def _get_face_rectangles(self): 369 | cvim = self._pil_to_opencv() 370 | return cv.HaarDetectObjects( 371 | cvim, 372 | self._get_face_classifier(), 373 | cv.CreateMemStorage(0), 374 | 1.3, # Scale factor 375 | 4, # Minimum neighbors 376 | 0, # HAAR Flags 377 | (20, 20)) 378 | 379 | def _get_face_position(self): 380 | rects = self._get_face_rectangles() 381 | if not rects: 382 | return (0.5, 0.5) 383 | xt, yt = (0.0, 0.0) 384 | for rect in rects: 385 | xt += rect[0][0] + (rect[0][2] / 2.0) 386 | yt += rect[0][1] + (rect[0][3] / 2.0) 387 | 388 | return (xt / (len(rects) * self.img.size[0]), 389 | yt / (len(rects) * self.img.size[1])) 390 | 391 | def _get_face_classifier(self): 392 | if not hasattr(Image, "_classifier"): 393 | classifier_path = os.path.abspath(Image._CLASSIFIER_PATH) 394 | Image._classifier = cv.Load(classifier_path) 395 | return Image._classifier 396 | 397 | def _pil_to_opencv(self): 398 | mono = self.img.convert("L") 399 | cvim = cv.CreateImageHeader(mono.size, cv.IPL_DEPTH_8U, 1) 400 | cv.SetData(cvim, mono.tobytes(), mono.size[0]) 401 | cv.EqualizeHist(cvim, cvim) 402 | return cvim 403 | 404 | @staticmethod 405 | def _normalize_options(options): 406 | opts = Image._DEFAULTS.copy() 407 | for k, v in options.items(): 408 | if v is not None: 409 | opts[k] = v 410 | opts["pil"] = dict( 411 | filter=_filters_to_pil.get(opts["filter"]), 412 | format=_formats_to_pil.get(opts["format"]), 413 | position=Image._get_custom_position(opts["position"])) 414 | 415 | if not opts["pil"]["position"]: 416 | opts["pil"]["position"] = _positions_to_ratios.get( 417 | opts["position"], None) 418 | 419 | return opts 420 | 421 | @staticmethod 422 | def _get_custom_position(pos): 423 | m = re.match(r'^(\d+(\.\d+)?),(\d+(\.\d+)?)$', pos) 424 | if not m: 425 | return None 426 | pos = (float(m.group(1)), float(m.group(3))) 427 | if pos[0] < 0.0 or pos[0] > 1.0 or pos[1] < 0.0 or pos[1] > 1.0: 428 | return None 429 | return pos 430 | 431 | @staticmethod 432 | def _isint(v, base=10): 433 | try: 434 | if type(v) is not bool: 435 | int(str(v), base) 436 | except ValueError: 437 | return False 438 | return True 439 | 440 | @staticmethod 441 | def _supports_alpha(fmt): 442 | # GIF intentionally omitted as it only supports transparency, 443 | # not an alpha channel. 444 | return fmt in ["PNG", "WEBP"] 445 | 446 | 447 | def color_hex_to_dec_tuple(color): 448 | """Converts a color from hexadecimal to decimal tuple, color can be in 449 | the following formats: 3-digit RGB, 4-digit ARGB, 6-digit RGB and 450 | 8-digit ARGB. 451 | """ 452 | assert len(color) in [3, 4, 6, 8] 453 | if len(color) in [3, 4]: 454 | color = "".join([c*2 for c in color]) 455 | n = int(color, 16) 456 | t = ((n >> 16) & 255, (n >> 8) & 255, n & 255) 457 | if len(color) == 8: 458 | t = t + ((n >> 24) & 255,) 459 | return t 460 | 461 | 462 | def main(): 463 | import sys 464 | import tornado.httpclient 465 | import tornado.options 466 | from tornado.options import define, options, parse_command_line 467 | 468 | define("operation", help="the operation to be performed", type=str, 469 | default="resize", metavar="|".join(["resize", "rotate", "none"])) 470 | define("width", help="the desired image width", type=int) 471 | define("height", help="the desired image height", type=int) 472 | define("mode", help="the resizing mode", 473 | metavar="|".join(Image.MODES), type=str) 474 | define("background", help="the hexadecimal fill background color", 475 | type=str) 476 | define("position", help="the crop position", 477 | metavar="|".join(Image.POSITIONS), type=str) 478 | define("filter", help="default filter to use when resizing", 479 | metavar="|".join(Image.FILTERS), type=str) 480 | define("degree", help="the desired rotation degree", type=str) 481 | define("expand", help="expand image size to accomodate rotation", type=int) 482 | define("rect", help="rectangle: x,y,w,h", type=str) 483 | define("format", help="default format to use when saving", 484 | metavar="|".join(Image.FORMATS), type=str) 485 | define("optimize", help="default to optimize when saving", type=int) 486 | define("progressive", help="default to progressive when saving", type=int) 487 | define("quality", help="default jpeg quality, 1-99 or keep") 488 | define("retain", help="default adaptive retain percent, 1-99", type=int) 489 | define("preserve_exif", help="default behavior for Exif data", type=int) 490 | 491 | args = parse_command_line() 492 | if not args: 493 | print("Missing image source url") 494 | sys.exit() 495 | elif options.operation == "region": 496 | if not options.rect: 497 | tornado.options.print_help() 498 | sys.exit() 499 | elif options.operation == "resize": 500 | if not options.width and not options.height: 501 | tornado.options.print_help() 502 | sys.exit() 503 | elif options.operation == "rotate": 504 | if not options.degree: 505 | tornado.options.print_help() 506 | sys.exit() 507 | elif options.operation != "noop": 508 | tornado.options.print_help() 509 | sys.exit() 510 | 511 | if args[0].startswith("http://") or args[0].startswith("https://"): 512 | client = tornado.httpclient.HTTPClient() 513 | resp = client.fetch(args[0]) 514 | image = Image(resp.buffer) 515 | else: 516 | image = Image(open(args[0], "r")) 517 | 518 | if options.operation == "resize": 519 | image.resize(options.width, options.height, mode=options.mode, 520 | filter=options.filter, background=options.background, 521 | position=options.position, retain=options.retain) 522 | elif options.operation == "rotate": 523 | image.rotate(options.degree, expand=options.expand) 524 | elif options.operation == "region": 525 | image.region(options.rect.split(",")) 526 | 527 | stream = image.save(format=options.format, 528 | optimize=options.optimize, 529 | background=options.background, 530 | quality=options.quality, 531 | progressive=options.progressive, 532 | preserve_exif=options.preserve_exif) 533 | try: 534 | sys.stdout.buffer.write(stream.read()) 535 | except AttributeError: 536 | sys.stdout.write(stream.read()) 537 | stream.close() 538 | 539 | 540 | if __name__ == "__main__": 541 | main() 542 | -------------------------------------------------------------------------------- /pilbox/test/image_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, with_statement 2 | 3 | import itertools 4 | import os 5 | import os.path 6 | import re 7 | 8 | import PIL.Image 9 | from tornado.test.util import unittest 10 | 11 | from pilbox import errors 12 | from pilbox.image import color_hex_to_dec_tuple, Image 13 | 14 | 15 | try: 16 | import cv 17 | except ImportError: 18 | cv = None 19 | 20 | 21 | DATADIR = os.path.join(os.path.dirname(__file__), "data") 22 | EXPECTED_DATADIR = os.path.join(DATADIR, "expected") 23 | 24 | 25 | def get_image_resize_cases(): 26 | """Returns a list of test cases of the form: 27 | [dict(source_path, expected_path, width, height, mode, ...), ...] 28 | """ 29 | cases = [] 30 | for filename in os.listdir(DATADIR): 31 | if not re.match(r"^test\d+\.[^\.]+$", filename): 32 | continue 33 | for criteria in _get_simple_criteria_combinations(): 34 | cases.append(_criteria_to_resize_case(filename, criteria)) 35 | 36 | cases.append(_criteria_to_resize_case( 37 | "test space.jpg", _get_simple_criteria_combinations()[0])) 38 | 39 | cases.append(_criteria_to_resize_case( 40 | "test-p-mode.gif", dict(width=100, height=100, format='jpeg', mode='crop'))) 41 | 42 | for criteria in _get_advanced_criteria_combinations(): 43 | cases.append(_criteria_to_resize_case("test-advanced.jpg", criteria)) 44 | 45 | for criteria in _get_example_criteria_combinations(): 46 | cases.append(_criteria_to_resize_case("example.jpg", criteria)) 47 | 48 | for criteria in _get_transparent_criteria_combinations(): 49 | cases.append(_criteria_to_resize_case("test2.png", criteria)) 50 | 51 | for criteria in _get_background_criteria_combinations(): 52 | cases.append(_criteria_to_resize_case("test-alpha1.png", criteria)) 53 | cases.append(_criteria_to_resize_case("test-alpha2.webp", criteria)) 54 | 55 | return list(filter(bool, cases)) 56 | 57 | 58 | def get_image_rotate_cases(): 59 | """Returns a list of test cases of the form: 60 | [dict(source_path, expected_path, degree, expand, ...), ...] 61 | """ 62 | criteria_combinations = _make_combinations( 63 | [dict(values=[[90, 180, 315], [1, 0]], 64 | fields=["degree", "expand"])]) 65 | 66 | cases = [] 67 | for criteria in criteria_combinations: 68 | cases.append(_criteria_to_rotate_case("test1.jpg", criteria)) 69 | 70 | 71 | criteria_combinations = _make_combinations( 72 | [dict(values=[["auto"], [0]], fields=["degree", "expand"])]) 73 | 74 | for criteria in criteria_combinations: 75 | cases.append(_criteria_to_rotate_case("test-orientation.jpg", criteria)) 76 | cases.append(_criteria_to_rotate_case("test-bad-exif.jpg", criteria)) 77 | cases.append(_criteria_to_rotate_case("test1.jpg", criteria)) 78 | cases.append(_criteria_to_rotate_case("test2.png", criteria)) 79 | 80 | return list(filter(bool, cases)) 81 | 82 | 83 | def get_image_region_cases(): 84 | """Returns a list of test cases of the form: 85 | [dict(source_path, expected_path, rect, ...), ...] 86 | """ 87 | criteria_combinations = _make_combinations( 88 | [dict(values=[["150,150,100,100", "200,175,50,50"]], 89 | fields=["rect"])]) 90 | 91 | cases = [] 92 | for criteria in criteria_combinations: 93 | cases.append(_criteria_to_region_case("test1.jpg", criteria)) 94 | 95 | return list(filter(bool, cases)) 96 | 97 | 98 | def get_image_chained_cases(): 99 | """Returns a list of test cases of the form: 100 | [dict(source_path, expected_path, operation, size, ...), ...] 101 | """ 102 | criteria_combinations = _make_combinations( 103 | [dict(values=[[("resize", "rotate"), ("rotate", "resize")], 104 | [(150, 75), (75, 150)], 105 | [90]], 106 | fields=["operation", "size", "degree"]), 107 | dict(values=[[("resize", "region", "rotate")], 108 | [(150, 75), (75, 150)], 109 | ["5,5,65,65"], 110 | [90]], 111 | fields=["operation", "size", "rect", "degree"]), 112 | dict(values=[[("region", "resize", "rotate")], 113 | [(150, 75), (75, 150)], 114 | ["50,50,150,150"], 115 | [90]], 116 | fields=["operation", "size", "rect", "degree"])]) 117 | 118 | cases = [] 119 | for criteria in criteria_combinations: 120 | cases.append(_criteria_to_chained_case("test1.jpg", criteria)) 121 | 122 | return list(filter(bool, cases)) 123 | 124 | 125 | def get_image_exif_cases(): 126 | """Returns a list of test cases of the form: 127 | [dict(source_path, expected_path, preserve_exif, ...), ...] 128 | """ 129 | criteria_combinations = _make_combinations( 130 | [dict(values=[[1, 0], [300], [300]], 131 | fields=["preserve_exif", "width", "height"])]) 132 | 133 | cases = [] 134 | for criteria in criteria_combinations: 135 | cases.append(_criteria_to_exif_case("test-orientation.jpg", criteria)) 136 | 137 | return list(filter(bool, cases)) 138 | 139 | 140 | class ImageTest(unittest.TestCase): 141 | 142 | def test_resize(self): 143 | for case in get_image_resize_cases(): 144 | if case.get("mode") == "crop" and case.get("position") == "face": 145 | continue 146 | self._assert_expected_resize(case) 147 | 148 | def test_rotate(self): 149 | for case in get_image_rotate_cases(): 150 | self._assert_expected_rotate(case) 151 | 152 | def test_region(self): 153 | for case in get_image_region_cases(): 154 | self._assert_expected_region(case) 155 | 156 | def test_chained(self): 157 | for case in get_image_chained_cases(): 158 | self._assert_expected_chained(case) 159 | 160 | def test_exif(self): 161 | for case in get_image_exif_cases(): 162 | self._assert_expected_exif(case) 163 | 164 | @unittest.skipIf(cv is None, "OpenCV is not installed") 165 | def test_face_crop_resize(self): 166 | for case in get_image_resize_cases(): 167 | if case.get("mode") == "crop" and case.get("position") == "face": 168 | self._assert_expected_resize(case) 169 | 170 | def test_valid_degree(self): 171 | for deg in [0, 90, "90", 45, "45", 300, 359]: 172 | Image.validate_degree(deg) 173 | 174 | def test_invalid_degree(self): 175 | for deg in [None, "a", "", 45.34, "93.20", -2, 360]: 176 | self.assertRaises(errors.DegreeError, Image.validate_degree, deg) 177 | 178 | def test_valid_dimensions(self): 179 | Image.validate_dimensions(100, 100) 180 | Image.validate_dimensions("100", "100") 181 | 182 | def test_invalid_dimensions_none(self): 183 | self.assertRaises( 184 | errors.DimensionsError, Image.validate_dimensions, None, None) 185 | self.assertRaises( 186 | errors.DimensionsError, Image.validate_dimensions, "", "") 187 | 188 | def test_invalid_dimensions_not_integer(self): 189 | self.assertRaises( 190 | errors.DimensionsError, Image.validate_dimensions, "a", 100) 191 | self.assertRaises( 192 | errors.DimensionsError, Image.validate_dimensions, 100, "a") 193 | 194 | def test_valid_rectangle(self): 195 | Image.validate_rectangle("100,100,200,200") 196 | Image.validate_rectangle("100,200,50,100") 197 | 198 | def test_invalid_rectangle(self): 199 | invalid_rectangles = ["", None, "100,100,200", "100,200,300,400.5", 200 | "0,-1,100,100", "100,100,-100,-100"] 201 | for rect in invalid_rectangles: 202 | self.assertRaises( 203 | errors.RectangleError, Image.validate_rectangle, rect) 204 | 205 | def test_out_of_bounds_rectangle(self): 206 | path = os.path.join(os.path.dirname(__file__), "data", "test1.jpg") 207 | invalid_rectangles = ["0,0,10000,10000", "10000,10000,0,0"] 208 | for rect in invalid_rectangles: 209 | with open(path, "rb") as f: 210 | img = Image(f) 211 | self.assertRaises( 212 | errors.RectangleError, img.region, rect.split(",")) 213 | 214 | def test_valid_default_options(self): 215 | Image.validate_options(dict()) 216 | 217 | def test_valid_default_options_with_empty_values(self): 218 | opts = dict(mode=None, filter=None, background=None, optimize=None, 219 | position=None, quality=None, progressive=None, 220 | preserve_exif=None) 221 | Image.validate_options(opts) 222 | 223 | def test_nonimage_file(self): 224 | with open(__file__, "rb") as f: 225 | self.assertRaises(errors.ImageFormatError, Image, f) 226 | 227 | def test_bad_image_format(self): 228 | path = os.path.join(DATADIR, "test-bad-format.ico") 229 | with open(path, "rb") as f: 230 | self.assertRaises(errors.ImageFormatError, Image, f) 231 | 232 | def test_bad_mode(self): 233 | self.assertRaises( 234 | errors.ModeError, Image.validate_options, dict(mode="foo")) 235 | 236 | def test_bad_filter(self): 237 | self.assertRaises( 238 | errors.FilterError, Image.validate_options, dict(filter="foo")) 239 | 240 | def test_bad_format(self): 241 | self.assertRaises( 242 | errors.FormatError, Image.validate_options, dict(format="foo")) 243 | 244 | def test_bad_background_invalid_number(self): 245 | self.assertRaises(errors.BackgroundError, 246 | Image.validate_options, 247 | dict(background="foo")) 248 | 249 | def test_bad_background_wrong_length(self): 250 | self.assertRaises(errors.BackgroundError, 251 | Image.validate_options, 252 | dict(background="0f")) 253 | self.assertRaises(errors.BackgroundError, 254 | Image.validate_options, 255 | dict(background="0f0f0")) 256 | self.assertRaises(errors.BackgroundError, 257 | Image.validate_options, 258 | dict(background="0f0f0f0f0")) 259 | 260 | def test_bad_position(self): 261 | self.assertRaises( 262 | errors.PositionError, Image.validate_options, dict(position="foo")) 263 | 264 | def test_bad_position_ratio(self): 265 | self.assertRaises(errors.PositionError, 266 | Image.validate_options, 267 | dict(position="1.2,5.6")) 268 | 269 | def test_valid_position_ratio(self): 270 | for pos in ["0.0,0.5", "1.0,1.0", "0.111111,0.999999"]: 271 | Image.validate_options(dict(position=pos)) 272 | 273 | def test_bad_quality_invalid_number(self): 274 | self.assertRaises( 275 | errors.QualityError, Image.validate_options, dict(quality="foo")) 276 | 277 | def test_bad_quality_invalid_range(self): 278 | self.assertRaises( 279 | errors.QualityError, Image.validate_options, dict(quality=101)) 280 | self.assertRaises( 281 | errors.QualityError, Image.validate_options, dict(quality=-1)) 282 | 283 | def test_bad_optimize_invalid_bool(self): 284 | self.assertRaises( 285 | errors.OptimizeError, Image.validate_options, dict(optimize="b")) 286 | 287 | def test_bad_preserve_exif_invalid_bool(self): 288 | self.assertRaises(errors.PreserveExifError, 289 | Image.validate_options, 290 | dict(preserve_exif="b")) 291 | 292 | def test_bad_progressive_invalid_bool(self): 293 | self.assertRaises(errors.ProgressiveError, 294 | Image.validate_options, 295 | dict(progressive="b")) 296 | 297 | def test_bad_retain_invalid_range(self): 298 | self.assertRaises( 299 | errors.RetainError, Image.validate_options, dict(retain=101)) 300 | self.assertRaises( 301 | errors.RetainError, Image.validate_options, dict(retain=-1)) 302 | 303 | def test_color_hex_to_dec_tuple(self): 304 | tests = [["fff", (255, 255, 255)], 305 | ["ccc", (204, 204, 204)], 306 | ["abc", (170, 187, 204)], 307 | ["ffffff", (255, 255, 255)], 308 | ["cccccc", (204, 204, 204)], 309 | ["abcdef", (171, 205, 239)], 310 | ["fabc", (170, 187, 204, 255)], 311 | ["0abc", (170, 187, 204, 0)], 312 | ["8abc", (170, 187, 204, 136)], 313 | ["80abcdef", (171, 205, 239, 128)], 314 | ["ffabcdef", (171, 205, 239, 255)], 315 | ["00abcdef", (171, 205, 239, 0)]] 316 | for test in tests: 317 | self.assertTupleEqual(color_hex_to_dec_tuple(test[0]), test[1]) 318 | 319 | def test_invalid_color_hex_to_dec_tuple(self): 320 | for color in ["9", "99", "99999", "9999999", "999999999"]: 321 | self.assertRaises(AssertionError, color_hex_to_dec_tuple, color) 322 | 323 | def test_save_failure(self): 324 | img = Image(os.path.join(DATADIR, 'test5.gif')) 325 | def _mock_save(*args, **kwargs): 326 | raise IOError('foo') 327 | img.img.save = _mock_save 328 | self.assertRaises(errors.ImageSaveError, 329 | lambda: img.save(format="webp")) 330 | 331 | def _assert_expected_resize(self, case): 332 | with open(case["source_path"], "rb") as f: 333 | img = Image(f).resize( 334 | case["width"], case["height"], mode=case["mode"], 335 | background=case.get("background"), filter=case.get("filter"), 336 | position=case.get("position"), retain=case.get("retain")) 337 | rv = img.save( 338 | format=case.get("format"), 339 | optimize=case.get("optimize"), 340 | background=case.get("background"), 341 | progressive=case.get("progressive"), 342 | quality=case.get("quality")) 343 | 344 | with open(case["expected_path"], "rb") as expected: 345 | msg = "%s does not match %s" \ 346 | % (case["source_path"], case["expected_path"]) 347 | self.assertEqual(rv.read(), expected.read(), msg) 348 | 349 | def _assert_expected_rotate(self, case): 350 | with open(case["source_path"], "rb") as f: 351 | 352 | img = Image(f).rotate( 353 | case["degree"], expand=case.get("expand"), 354 | filter=case.get("filter")) 355 | rv = img.save( 356 | format=case.get("format"), 357 | optimize=case.get("optimize"), 358 | progressive=case.get("progressive"), 359 | quality=case.get("quality")) 360 | 361 | with open(case["expected_path"], "rb") as expected: 362 | msg = "%s does not match %s" \ 363 | % (case["source_path"], case["expected_path"]) 364 | self.assertEqual(rv.read(), expected.read(), msg) 365 | 366 | def _assert_expected_region(self, case): 367 | with open(case["source_path"], "rb") as f: 368 | img = Image(f).region(case["rect"].split(",")) 369 | rv = img.save( 370 | format=case.get("format"), 371 | optimize=case.get("optimize"), 372 | progressive=case.get("progressive"), 373 | quality=case.get("quality")) 374 | 375 | with open(case["expected_path"], "rb") as expected: 376 | msg = "%s does not match %s" \ 377 | % (case["source_path"], case["expected_path"]) 378 | self.assertEqual(rv.read(), expected.read(), msg) 379 | 380 | def _assert_expected_chained(self, case): 381 | with open(case["source_path"], "rb") as f: 382 | 383 | img = Image(f) 384 | for operation in case["operation"]: 385 | if operation == "resize": 386 | img.resize(case["width"], case["height"]) 387 | elif operation == "rotate": 388 | img.rotate(case["degree"]) 389 | elif operation == "region": 390 | img.region(case["rect"].split(",")) 391 | 392 | rv = img.save() 393 | 394 | with open(case["expected_path"], "rb") as expected: 395 | msg = "%s does not match %s" \ 396 | % (case["source_path"], case["expected_path"]) 397 | self.assertEqual(rv.read(), expected.read(), msg) 398 | 399 | def _assert_expected_exif(self, case): 400 | with open(case["source_path"], "rb") as f: 401 | img = Image(f).resize(case["width"], case["height"]) 402 | rv = img.save(preserve_exif=case['preserve_exif']) 403 | 404 | with open(case["expected_path"], "rb") as expected: 405 | msg = "%s does not match %s" \ 406 | % (case["source_path"], case["expected_path"]) 407 | self.assertEqual(rv.read(), expected.read(), msg) 408 | 409 | 410 | def _get_simple_criteria_combinations(): 411 | return _make_combinations( 412 | [dict(values=[Image.MODES, [(400, 300), (300, 300), (100, 200)]], 413 | fields=["mode", "size"]), 414 | dict(values=[["crop"], [(200, 100)], ["center", "face"]], 415 | fields=["mode", "size", "position"])]) 416 | 417 | 418 | def _get_example_criteria_combinations(): 419 | return [dict(mode="adapt", width=500, height=400, retain=80), 420 | dict(mode="adapt", width=500, height=400, retain=99, 421 | background="ccc"), 422 | dict(mode="clip", width=500, height=400), 423 | dict(mode="crop", width=500, height=400), 424 | dict(mode="fill", width=500, height=400, background="ccc"), 425 | dict(mode="scale", width=500, height=400)] 426 | 427 | 428 | def _get_advanced_criteria_combinations(): 429 | return _make_combinations( 430 | [dict(values=[["adapt"], [(125, 75)], [99, 80, 60, 40]], 431 | fields=["mode", "size", "retain"]), 432 | dict(values=[["fill"], [(125, 75)], ["F00", "cccccc"]], 433 | fields=["mode", "size", "background"]), 434 | dict(values=[["crop"], [(125, 75)], Image.POSITIONS], 435 | fields=["mode", "size", "position"]), 436 | dict(values=[["crop"], [(125, 75)], ["0.25,0.75", "0.25,0.25"]], 437 | fields=["mode", "size", "position"]), 438 | dict(values=[["crop"], [(125, 75)], Image.FILTERS], 439 | fields=["mode", "size", "filter"]), 440 | dict(values=[["crop"], [(125, 75)], [50, 75, 90, "keep"]], 441 | fields=["mode", "size", "quality"]), 442 | dict(values=[["crop"], [(125, 75)], [1, 0]], 443 | fields=["mode", "size", "optimize"]), 444 | dict(values=[["crop"], [(125, 75)], [1, 0]], 445 | fields=["mode", "size", "progressive"]), 446 | dict(values=[Image.MODES, [(125, None), (None, 125)]], 447 | fields=["mode", "size"]), 448 | dict(values=[["crop"], [(125, 75)], Image.FORMATS], 449 | fields=["mode", "size", "format"])]) 450 | 451 | 452 | def _get_transparent_criteria_combinations(): 453 | return _make_combinations( 454 | [dict(values=[["fill"], [(75, 125)], ["1ccc", "a0cccccc"]], 455 | fields=["mode", "size", "background"])]) 456 | 457 | def _get_background_criteria_combinations(): 458 | return _make_combinations( 459 | [dict(values=[["crop"], [(125, 125)], ["0fff", "000", "fff", "a0cccccc"], ["jpg", "png", "gif", "webp"]], 460 | fields=["mode", "size", "background", "format"])]) 461 | 462 | def _make_combinations(choices): 463 | combos = [] 464 | for choice in choices: 465 | for a in list(itertools.product(*choice["values"])): 466 | combo = dict(zip(choice["fields"], a)) 467 | if "size" in combo: 468 | combo["width"] = combo["size"][0] 469 | combo["height"] = combo["size"][1] 470 | del combo["size"] 471 | combos.append(combo) 472 | return combos 473 | 474 | 475 | def _criteria_to_resize_case(filename, criteria): 476 | m = re.match(r"^([^\.]+)\.([^\.]+)$", filename) 477 | if not m: 478 | return None 479 | case = dict(source_path=os.path.join(DATADIR, filename)) 480 | case.update(criteria) 481 | fields = ["mode", "filter", "quality", "background", 482 | "position", "optimize", "progressive", "retain"] 483 | opts_desc = "-".join(["%s=%s" % (x, str(criteria.get(x))) 484 | for x in fields if criteria.get(x)]) 485 | expected = "%s-%sx%s%s.%s" \ 486 | % (m.group(1), 487 | criteria.get("width") or "", 488 | criteria.get("height") or "", 489 | ("-%s" % opts_desc) if opts_desc else "", 490 | criteria.get("format") or m.group(2)) 491 | case["expected_path"] = os.path.join(EXPECTED_DATADIR, expected) 492 | return case 493 | 494 | 495 | def _criteria_to_rotate_case(filename, criteria): 496 | m = re.match(r"^([^\.]+)\.([^\.]+)$", filename) 497 | if not m: 498 | return None 499 | case = dict(source_path=os.path.join(DATADIR, filename)) 500 | case.update(criteria) 501 | fields = ["degree", "expand"] 502 | opts_desc = "-".join(["%s=%s" % (x, str(criteria.get(x))) 503 | for x in fields if criteria.get(x)]) 504 | expected = "%s-rotate%s.%s" \ 505 | % (m.group(1), 506 | ("-%s" % opts_desc) if opts_desc else "", 507 | criteria.get("format") or m.group(2)) 508 | case["expected_path"] = os.path.join(EXPECTED_DATADIR, expected) 509 | return case 510 | 511 | 512 | def _criteria_to_region_case(filename, criteria): 513 | m = re.match(r"^([^\.]+)\.([^\.]+)$", filename) 514 | if not m: 515 | return None 516 | case = dict(source_path=os.path.join(DATADIR, filename)) 517 | case.update(criteria) 518 | fields = ["rect"] 519 | opts_desc = "-".join(["%s=%s" % (x, str(criteria.get(x))) 520 | for x in fields if criteria.get(x)]) 521 | expected = "%s-region%s.%s" \ 522 | % (m.group(1), 523 | ("-%s" % opts_desc) if opts_desc else "", 524 | criteria.get("format") or m.group(2)) 525 | case["expected_path"] = os.path.join(EXPECTED_DATADIR, expected) 526 | return case 527 | 528 | 529 | def _criteria_to_chained_case(filename, criteria): 530 | m = re.match(r"^([^\.]+)\.([^\.]+)$", filename) 531 | if not m: 532 | return None 533 | case = dict(source_path=os.path.join(DATADIR, filename)) 534 | case.update(criteria) 535 | fields = ["degree", "rect"] 536 | opts_desc = "-".join(["%s=%s" % (x, str(criteria.get(x))) 537 | for x in fields if criteria.get(x)]) 538 | expected = "%s-chained-%s-%sx%s%s.%s" \ 539 | % (m.group(1), 540 | ",".join(criteria.get("operation", [])), 541 | criteria.get("width") or "", 542 | criteria.get("height") or "", 543 | ("-%s" % opts_desc) if opts_desc else "", 544 | m.group(2)) 545 | case["expected_path"] = os.path.join(EXPECTED_DATADIR, expected) 546 | return case 547 | 548 | 549 | def _criteria_to_exif_case(filename, criteria): 550 | m = re.match(r"^([^\.]+)\.([^\.]+)$", filename) 551 | if not m: 552 | return None 553 | case = dict(source_path=os.path.join(DATADIR, filename)) 554 | case.update(criteria) 555 | fields = ["preserve_exif"] 556 | opts_desc = "-".join(["%s=%s" % (x, str(criteria.get(x))) 557 | for x in fields if criteria.get(x) is not None]) 558 | expected = "%s-exif-%s.%s" \ 559 | % (m.group(1), 560 | opts_desc or "", 561 | m.group(2)) 562 | case["expected_path"] = os.path.join(EXPECTED_DATADIR, expected) 563 | return case 564 | -------------------------------------------------------------------------------- /pilbox/test/app_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, with_statement 2 | 3 | import logging 4 | import os.path 5 | import time 6 | 7 | import PIL.Image 8 | import tornado.escape 9 | import tornado.gen 10 | import tornado.ioloop 11 | import tornado.web 12 | from tornado.test.util import unittest 13 | from tornado.testing import AsyncHTTPTestCase 14 | 15 | from pilbox import errors 16 | from pilbox.app import PilboxApplication 17 | from pilbox.signature import sign 18 | from pilbox.test import image_test 19 | 20 | try: 21 | from io import BytesIO 22 | except ImportError: 23 | from cStringIO import StringIO as BytesIO 24 | 25 | try: 26 | from urllib import urlencode, quote 27 | except ImportError: 28 | from urllib.parse import urlencode, quote 29 | 30 | try: 31 | import cv 32 | except ImportError: 33 | cv = None 34 | 35 | try: 36 | import pycurl 37 | except ImportError: 38 | pycurl = None 39 | 40 | 41 | logger = logging.getLogger("tornado.application") 42 | 43 | 44 | class _AppAsyncMixin(object): 45 | def fetch_error(self, code, *args, **kwargs): 46 | response = self.fetch(*args, **kwargs) 47 | self.assertEqual(response.code, code) 48 | self.assertEqual(response.headers.get("Content-Type", None), 49 | "application/json") 50 | return tornado.escape.json_decode(response.body) 51 | 52 | def fetch_success(self, *args, **kwargs): 53 | response = self.fetch(*args, **kwargs) 54 | msg = "failed to fetch %s, received %d with %s" \ 55 | % (args[0], response.code, response.body) 56 | self.assertEqual(response.code, 200, msg) 57 | return response 58 | 59 | def get_image_resize_cases(self): 60 | cases = image_test.get_image_resize_cases() 61 | m = dict(background="bg", filter="filter", format="fmt", 62 | optimize="opt", position="pos", progressive="prog", 63 | quality="q", retain="retain") 64 | for i, case in enumerate(cases): 65 | path = "/test/data/%s" % quote(os.path.basename(case["source_path"])) 66 | cases[i]["source_query_params"] = dict( 67 | url=self.get_url(path), 68 | w=case["width"] or "", 69 | h=case["height"] or "", 70 | mode=case["mode"]) 71 | for k in m.keys(): 72 | if k in case: 73 | cases[i]["source_query_params"][m.get(k)] = case[k] 74 | cases[i]["content_type"] = self._format_to_content_type( 75 | case.get("format")) 76 | return cases 77 | 78 | def get_image_rotate_cases(self): 79 | cases = image_test.get_image_rotate_cases() 80 | m = dict(expand="expand", format="fmt", optimize="opt", quality="q") 81 | for i, case in enumerate(cases): 82 | path = "/test/data/%s" % quote(os.path.basename(case["source_path"])) 83 | cases[i]["source_query_params"] = dict( 84 | op="rotate", 85 | url=self.get_url(path), 86 | deg=case["degree"]) 87 | for k in m.keys(): 88 | if k in case: 89 | cases[i]["source_query_params"][m.get(k)] = case[k] 90 | cases[i]["content_type"] = self._format_to_content_type( 91 | case.get("format")) 92 | 93 | return cases 94 | 95 | def get_image_region_cases(self): 96 | cases = image_test.get_image_region_cases() 97 | m = dict(expand="expand", format="fmt", optimize="opt", quality="q") 98 | for i, case in enumerate(cases): 99 | path = "/test/data/%s" % quote(os.path.basename(case["source_path"])) 100 | cases[i]["source_query_params"] = dict( 101 | op="region", 102 | url=self.get_url(path), 103 | rect=case["rect"]) 104 | for k in m.keys(): 105 | if k in case: 106 | cases[i]["source_query_params"][m.get(k)] = case[k] 107 | cases[i]["content_type"] = self._format_to_content_type( 108 | case.get("format")) 109 | 110 | return cases 111 | 112 | def get_image_chained_cases(self): 113 | cases = image_test.get_image_chained_cases() 114 | for i, case in enumerate(cases): 115 | path = "/test/data/%s" % quote(os.path.basename(case["source_path"])) 116 | cases[i]["source_query_params"] = dict( 117 | op=",".join(case["operation"]), 118 | url=self.get_url(path), 119 | w=case["width"] or "", 120 | h=case["height"] or "", 121 | deg=case.get("degree") or "", 122 | rect=case.get("rect") or "") 123 | cases[i]["content_type"] = self._format_to_content_type( 124 | case.get("format")) 125 | 126 | return cases 127 | 128 | def get_image_exif_cases(self): 129 | cases = image_test.get_image_exif_cases() 130 | m = dict(preserve_exif="exif") 131 | for i, case in enumerate(cases): 132 | path = "/test/data/%s" % quote(os.path.basename(case["source_path"])) 133 | cases[i]["source_query_params"] = dict( 134 | url=self.get_url(path), 135 | w=case["width"] or "", 136 | h=case["height"] or "") 137 | for k in m.keys(): 138 | if k in case: 139 | cases[i]["source_query_params"][m.get(k)] = case[k] 140 | cases[i]["content_type"] = self._format_to_content_type( 141 | case.get("format")) 142 | 143 | return cases 144 | 145 | def _format_to_content_type(self, fmt): 146 | if fmt in ["jpeg", "jpg"]: 147 | return "image/jpeg" 148 | elif fmt == "png": 149 | return "image/png" 150 | elif fmt == "webp": 151 | return "image/webp" 152 | elif fmt == "gif": 153 | return "image/gif" 154 | return None 155 | 156 | 157 | class _PilboxTestApplication(PilboxApplication): 158 | def get_handlers(self): 159 | path = os.path.join(os.path.dirname(__file__), "data") 160 | handlers = [(r"/test/data/test-delayed.jpg", _DelayedHandler), 161 | (r"/test/data/test-user-agent.jpg", _UserAgentHandler), 162 | (r"/test/data/(.*)", 163 | tornado.web.StaticFileHandler, 164 | {"path": path})] 165 | handlers.extend(super(_PilboxTestApplication, self).get_handlers()) 166 | return handlers 167 | 168 | 169 | class _DelayedHandler(tornado.web.RequestHandler): 170 | 171 | @tornado.gen.coroutine 172 | def get(self): 173 | yield tornado.gen.sleep(float(self.get_argument("delay", 0.0))) 174 | self.finish() 175 | 176 | 177 | class _UserAgentHandler(tornado.web.RequestHandler): 178 | 179 | def get(self): 180 | ua = self.request.headers.get("User-Agent", "") 181 | expected_ua = self.get_argument("ua", "") 182 | if ua != expected_ua: 183 | self.set_status(400) 184 | self.finish("") 185 | return 186 | 187 | self.set_status(200) 188 | self.set_header("Content-Type", "image/jpeg") 189 | img = PIL.Image.new('RGB', (1, 1)) 190 | outfile = BytesIO() 191 | img.save(outfile, "JPEG") 192 | outfile.seek(0) 193 | for block in iter(lambda: outfile.read(65536), b""): 194 | self.write(block) 195 | outfile.close() 196 | self.finish() 197 | 198 | 199 | class AppTest(AsyncHTTPTestCase, _AppAsyncMixin): 200 | def get_app(self): 201 | return _PilboxTestApplication(timeout=10.0) 202 | 203 | def test_missing_url(self): 204 | qs = urlencode(dict(w=1, h=1)) 205 | resp = self.fetch_error(400, "/?%s" % qs) 206 | self.assertEqual(resp.get("error_code"), errors.UrlError.get_code()) 207 | 208 | def test_invalid_operation(self): 209 | qs = urlencode(dict(url="http://foo.co/x.jpg", op="a")) 210 | resp = self.fetch_error(400, "/?%s" % qs) 211 | self.assertEqual(resp.get("error_code"), 212 | errors.OperationError.get_code()) 213 | 214 | def test_missing_dimensions(self): 215 | qs = urlencode(dict(url="http://foo.co/x.jpg")) 216 | resp = self.fetch_error(400, "/?%s" % qs) 217 | self.assertEqual(resp.get("error_code"), 218 | errors.DimensionsError.get_code()) 219 | 220 | def test_invalid_width(self): 221 | qs = urlencode(dict(url="http://foo.co/x.jpg", w="a", h=1)) 222 | resp = self.fetch_error(400, "/?%s" % qs) 223 | self.assertEqual(resp.get("error_code"), 224 | errors.DimensionsError.get_code()) 225 | 226 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=15001, h=1)) 227 | resp = self.fetch_error(400, "/?%s" % qs) 228 | self.assertEqual(resp.get("error_code"), 229 | errors.DimensionsError.get_code()) 230 | 231 | def test_invalid_height(self): 232 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h="a")) 233 | resp = self.fetch_error(400, "/?%s" % qs) 234 | self.assertEqual(resp.get("error_code"), 235 | errors.DimensionsError.get_code()) 236 | 237 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=15001)) 238 | resp = self.fetch_error(400, "/?%s" % qs) 239 | self.assertEqual(resp.get("error_code"), 240 | errors.DimensionsError.get_code()) 241 | 242 | def test_invalid_degree(self): 243 | qs = urlencode(dict(url="http://foo.co/x.jpg", op="rotate", deg="a")) 244 | resp = self.fetch_error(400, "/?%s" % qs) 245 | self.assertEqual(resp.get("error_code"), errors.DegreeError.get_code()) 246 | 247 | def test_invalid_rectangle(self): 248 | qs = urlencode(dict(url="http://foo.co/x.jpg", op="region", rect="a")) 249 | resp = self.fetch_error(400, "/?%s" % qs) 250 | self.assertEqual(resp.get("error_code"), 251 | errors.RectangleError.get_code()) 252 | 253 | def test_invalid_mode(self): 254 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, mode="foo")) 255 | resp = self.fetch_error(400, "/?%s" % qs) 256 | self.assertEqual(resp.get("error_code"), errors.ModeError.get_code()) 257 | 258 | def test_invalid_hexadecimal_background(self): 259 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, 260 | mode="fill", bg="r")) 261 | resp = self.fetch_error(400, "/?%s" % qs) 262 | self.assertEqual(resp.get("error_code"), 263 | errors.BackgroundError.get_code()) 264 | 265 | def test_invalid_long_background(self): 266 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, 267 | mode="fill", bg="0f0f0f0f0")) 268 | resp = self.fetch_error(400, "/?%s" % qs) 269 | self.assertEqual(resp.get("error_code"), 270 | errors.BackgroundError.get_code()) 271 | 272 | def test_invalid_position(self): 273 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, pos="foo")) 274 | resp = self.fetch_error(400, "/?%s" % qs) 275 | self.assertEqual(resp.get("error_code"), 276 | errors.PositionError.get_code()) 277 | 278 | def test_invalid_position_ratio(self): 279 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, pos="1.2,5.6")) 280 | resp = self.fetch_error(400, "/?%s" % qs) 281 | self.assertEqual(resp.get("error_code"), 282 | errors.PositionError.get_code()) 283 | 284 | def test_invalid_filter(self): 285 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, filter="bar")) 286 | resp = self.fetch_error(400, "/?%s" % qs) 287 | self.assertEqual(resp.get("error_code"), errors.FilterError.get_code()) 288 | 289 | def test_invalid_format(self): 290 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, fmt="foo")) 291 | resp = self.fetch_error(400, "/?%s" % qs) 292 | self.assertEqual(resp.get("error_code"), errors.FormatError.get_code()) 293 | 294 | def test_invalid_optimize(self): 295 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, opt="a")) 296 | resp = self.fetch_error(400, "/?%s" % qs) 297 | self.assertEqual(resp.get("error_code"), 298 | errors.OptimizeError.get_code()) 299 | 300 | def test_invalid_integer_quality(self): 301 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, q="a")) 302 | resp = self.fetch_error(400, "/?%s" % qs) 303 | self.assertEqual(resp.get("error_code"), errors.QualityError.get_code()) 304 | 305 | def test_outofbounds_quality(self): 306 | qs = urlencode(dict(url="http://foo.co/x.jpg", w=1, h=1, q=200)) 307 | resp = self.fetch_error(400, "/?%s" % qs) 308 | self.assertEqual(resp.get("error_code"), errors.QualityError.get_code()) 309 | 310 | def test_nonimage_file(self): 311 | path = "/test/data/test-nonimage.txt" 312 | qs = urlencode(dict(url=self.get_url(path), w=1, h=1)) 313 | resp = self.fetch_error(415, "/?%s" % qs) 314 | self.assertEqual(resp.get("error_code"), 315 | errors.ImageFormatError.get_code()) 316 | 317 | def test_unsupported_image_format(self): 318 | path = "/test/data/test-bad-format.ico" 319 | qs = urlencode(dict(url=self.get_url(path), w=1, h=1)) 320 | resp = self.fetch_error(415, "/?%s" % qs) 321 | self.assertEqual(resp.get("error_code"), 322 | errors.ImageFormatError.get_code()) 323 | 324 | def test_retain_incorrect_format(self): 325 | url = self.get_url("/test/data/test-incorrect-format.png") 326 | qs = urlencode(dict(url=url, w=1, h=1)) 327 | resp = self.fetch_success("/?%s" % qs) 328 | self.assertEqual(resp.headers.get("Content-Type", None), "image/png") 329 | 330 | def test_not_found(self): 331 | path = "/test/data/test-not-found.jpg" 332 | qs = urlencode(dict(url=self.get_url(path), w=1, h=1)) 333 | resp = self.fetch_error(404, "/?%s" % qs) 334 | self.assertEqual(resp.get("error_code"), errors.FetchError.get_code()) 335 | 336 | def test_not_connect(self): 337 | qs = urlencode(dict(url="http://a.com/a.jpg", w=1, h=1)) 338 | resp = self.fetch_error(404, "/?%s" % qs) 339 | self.assertEqual(resp.get("error_code"), errors.FetchError.get_code()) 340 | 341 | def test_invalid_protocol(self): 342 | path = os.path.join(os.path.dirname(__file__), "data", "test1.jpg") 343 | qs = urlencode(dict(url="file://%s" % path, w=1, h=1)) 344 | resp = self.fetch_error(400, "/?%s" % qs) 345 | self.assertEqual(resp.get("error_code"), errors.UrlError.get_code()) 346 | 347 | def test_valid_noop(self): 348 | url = self.get_url("/test/data/test1.jpg") 349 | qs = urlencode(dict(url=url, op="noop")) 350 | resp = self.fetch_success("/?%s" % qs) 351 | expected_path = os.path.join( 352 | os.path.dirname(__file__), "data", "test1.jpg") 353 | msg = "/?%s does not match %s" % (qs, expected_path) 354 | with open(expected_path, "rb") as expected: 355 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 356 | 357 | def test_valid_resize(self): 358 | cases = self.get_image_resize_cases() 359 | for case in cases: 360 | if case.get("mode") == "crop" and case.get("position") == "face": 361 | continue 362 | self._assert_expected_case(case) 363 | 364 | def test_valid_rotate(self): 365 | cases = self.get_image_rotate_cases() 366 | for case in cases: 367 | self._assert_expected_case(case) 368 | 369 | def test_valid_region(self): 370 | cases = self.get_image_region_cases() 371 | for case in cases: 372 | self._assert_expected_case(case) 373 | 374 | def test_valid_chained(self): 375 | cases = self.get_image_chained_cases() 376 | for case in cases: 377 | self._assert_expected_case(case) 378 | 379 | def test_valid_exif(self): 380 | cases = self.get_image_exif_cases() 381 | for case in cases: 382 | self._assert_expected_case(case) 383 | 384 | @unittest.skipIf(cv is None, "OpenCV is not installed") 385 | def test_valid_face(self): 386 | cases = self.get_image_resize_cases() 387 | for case in cases: 388 | if case.get("mode") == "crop" and case.get("position") == "face": 389 | self._assert_expected_case(case) 390 | 391 | def _assert_expected_case(self, case): 392 | qs = urlencode(case["source_query_params"]) 393 | resp = self.fetch_success("/?%s" % qs) 394 | if case["content_type"]: 395 | self.assertEqual(resp.headers.get("Content-Type", None), 396 | case["content_type"]) 397 | msg = "/?%s does not match %s" \ 398 | % (qs, case["expected_path"]) 399 | with open(case["expected_path"], "rb") as expected: 400 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 401 | 402 | 403 | class AppImplicitBaseUrlTest(AsyncHTTPTestCase, _AppAsyncMixin): 404 | def get_app(self): 405 | return _PilboxTestApplication( 406 | implicit_base_url=self.get_url("/")) 407 | 408 | def test_missing_url(self): 409 | qs = urlencode(dict(w=1, h=1)) 410 | resp = self.fetch_error(400, "/?%s" % qs) 411 | self.assertEqual(resp.get("error_code"), errors.UrlError.get_code()) 412 | 413 | def test_url(self): 414 | url = self.get_url("/test/data/test1.jpg") 415 | qs = urlencode(dict(url=url, op="noop")) 416 | resp = self.fetch_success("/?%s" % qs) 417 | expected_path = os.path.join( 418 | os.path.dirname(__file__), "data", "test1.jpg") 419 | msg = "/?%s does not match %s" % (qs, expected_path) 420 | with open(expected_path, "rb") as expected: 421 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 422 | 423 | def test_path(self): 424 | url_path = "/test/data/test1.jpg" 425 | qs = urlencode(dict(url=url_path, op="noop")) 426 | resp = self.fetch_success("/?%s" % qs) 427 | expected_path = os.path.join( 428 | os.path.dirname(__file__), "data", "test1.jpg") 429 | msg = "/?%s does not match %s" % (qs, expected_path) 430 | with open(expected_path, "rb") as expected: 431 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 432 | 433 | def test_invalid_protocol(self): 434 | path = os.path.join(os.path.dirname(__file__), "data", "test1.jpg") 435 | qs = urlencode(dict(url="file://%s" % path, w=1, h=1)) 436 | resp = self.fetch_error(400, "/?%s" % qs) 437 | self.assertEqual(resp.get("error_code"), errors.UrlError.get_code()) 438 | 439 | 440 | class AppAllowedOperationsTest(AsyncHTTPTestCase, _AppAsyncMixin): 441 | def get_app(self): 442 | return _PilboxTestApplication(allowed_operations=['noop']) 443 | 444 | def test_invalid_operation(self): 445 | qs = urlencode(dict(url="http://foo.co/x.jpg", op="resize")) 446 | resp = self.fetch_error(400, "/?%s" % qs) 447 | self.assertEqual(resp.get("error_code"), 448 | errors.OperationError.get_code()) 449 | 450 | def test_valid_noop(self): 451 | url = self.get_url("/test/data/test1.jpg") 452 | qs = urlencode(dict(url=url, op="noop")) 453 | resp = self.fetch_success("/?%s" % qs) 454 | expected_path = os.path.join( 455 | os.path.dirname(__file__), "data", "test1.jpg") 456 | msg = "/?%s does not match %s" % (qs, expected_path) 457 | with open(expected_path, "rb") as expected: 458 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 459 | 460 | 461 | class AppDefaultOperationTest(AsyncHTTPTestCase, _AppAsyncMixin): 462 | def get_app(self): 463 | return _PilboxTestApplication(operation='noop') 464 | 465 | def test_valid_default_operation(self): 466 | url = self.get_url("/test/data/test1.jpg") 467 | qs = urlencode(dict(url=url)) 468 | resp = self.fetch_success("/?%s" % qs) 469 | expected_path = os.path.join( 470 | os.path.dirname(__file__), "data", "test1.jpg") 471 | msg = "/?%s does not match %s" % (qs, expected_path) 472 | with open(expected_path, "rb") as expected: 473 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 474 | 475 | 476 | class AppMaxOperationsTest(AsyncHTTPTestCase, _AppAsyncMixin): 477 | def get_app(self): 478 | return _PilboxTestApplication(max_operations=1) 479 | 480 | def test_invalid_max_operations(self): 481 | qs = urlencode(dict( 482 | url=self.get_url("/test/data/test1.jpg"), 483 | op="resize,rotate", 484 | deg=180, 485 | w=100, 486 | h=100 487 | )) 488 | resp = self.fetch_error(400, "/?%s" % qs) 489 | self.assertEqual(resp.get("error_code"), 490 | errors.OperationError.get_code()) 491 | 492 | def test_valid_max_operations(self): 493 | url = self.get_url("/test/data/test1.jpg") 494 | qs = urlencode(dict(url=url, op="noop")) 495 | resp = self.fetch_success("/?%s" % qs) 496 | expected_path = os.path.join( 497 | os.path.dirname(__file__), "data", "test1.jpg") 498 | msg = "/?%s does not match %s" % (qs, expected_path) 499 | with open(expected_path, "rb") as expected: 500 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 501 | 502 | 503 | class AppOverrideContentTypeTest(AsyncHTTPTestCase, _AppAsyncMixin): 504 | def get_app(self): 505 | return _PilboxTestApplication(content_type_from_image=True) 506 | 507 | def test_override_unknown_format(self): 508 | url = self.get_url("/test/data/test-unknown-format") 509 | qs = urlencode(dict(url=url, w=1, h=1)) 510 | resp = self.fetch_success("/?%s" % qs) 511 | self.assertEqual(resp.headers.get("Content-Type", None), "image/jpeg") 512 | 513 | def test_override_incorrect_format(self): 514 | url = self.get_url("/test/data/test-incorrect-format.png") 515 | qs = urlencode(dict(url=url, w=1, h=1)) 516 | resp = self.fetch_success("/?%s" % qs) 517 | self.assertEqual(resp.headers.get("Content-Type", None), "image/jpeg") 518 | 519 | 520 | class AppRestrictedTest(AsyncHTTPTestCase, _AppAsyncMixin): 521 | KEY = "abcdef" 522 | NAME = "abc" 523 | 524 | def get_app(self): 525 | return _PilboxTestApplication( 526 | client_name=self.NAME, 527 | client_key=self.KEY, 528 | allowed_hosts=["foo.co", "bar.io", "localhost", "127.0.0.1"], 529 | timeout=10.0) 530 | 531 | def test_missing_client_name(self): 532 | params = dict(url="http://foo.co/x.jpg", w=1, h=1) 533 | qs = sign(self.KEY, urlencode(params)) 534 | resp = self.fetch_error(403, "/?%s" % qs) 535 | self.assertEqual(resp.get("error_code"), errors.ClientError.get_code()) 536 | 537 | def test_bad_client_name(self): 538 | params = dict(url="http://foo.co/x.jpg", w=1, h=1, client="123") 539 | qs = sign(self.KEY, urlencode(params)) 540 | resp = self.fetch_error(403, "/?%s" % qs) 541 | self.assertEqual(resp.get("error_code"), errors.ClientError.get_code()) 542 | 543 | def test_missing_signature(self): 544 | params = dict(url="http://foo.co/x.jpg", w=1, h=1, client=self.NAME) 545 | qs = urlencode(params) 546 | resp = self.fetch_error(403, "/?%s" % qs) 547 | self.assertEqual(resp.get("error_code"), 548 | errors.SignatureError.get_code()) 549 | 550 | def test_bad_signature(self): 551 | params = dict(url="http://foo.co/x.jpg", w=1, h=1, 552 | client=self.NAME, sig="abc123") 553 | qs = urlencode(params) 554 | resp = self.fetch_error(403, "/?%s" % qs) 555 | self.assertEqual(resp.get("error_code"), 556 | errors.SignatureError.get_code()) 557 | 558 | def test_bad_host(self): 559 | params = dict(url="http://bar.co/x.jpg", w=1, h=1, client=self.NAME) 560 | qs = sign(self.KEY, urlencode(params)) 561 | resp = self.fetch_error(403, "/?%s" % qs) 562 | self.assertEqual(resp.get("error_code"), errors.HostError.get_code()) 563 | 564 | def test_valid(self): 565 | cases = self.get_image_resize_cases() 566 | for case in cases: 567 | if case.get("mode") == "crop" and case.get("position") == "face": 568 | continue 569 | params = case["source_query_params"] 570 | params["client"] = self.NAME 571 | qs = sign(self.KEY, urlencode(params)) 572 | resp = self.fetch_success("/?%s" % qs) 573 | msg = "/?%s does not match %s" \ 574 | % (qs, case["expected_path"]) 575 | with open(case["expected_path"], "rb") as expected: 576 | self.assertEqual(resp.buffer.read(), expected.read(), msg) 577 | 578 | 579 | class AppSlowTest(AsyncHTTPTestCase, _AppAsyncMixin): 580 | def get_app(self): 581 | return _PilboxTestApplication(timeout=0.5) 582 | 583 | def test_timeout(self): 584 | url = self.get_url("/test/data/test-delayed.jpg?delay=1.0") 585 | qs = urlencode(dict(url=url, w=1, h=1)) 586 | resp = self.fetch_error(404, "/?%s" % qs) 587 | self.assertEqual(resp.get("error_code"), errors.FetchError.get_code()) 588 | 589 | 590 | class AppUserAgentTest(AsyncHTTPTestCase, _AppAsyncMixin): 591 | ua = "foo" 592 | 593 | def get_app(self): 594 | return _PilboxTestApplication(user_agent=AppUserAgentTest.ua) 595 | 596 | def test_incorrect_user_agent(self): 597 | url = self.get_url("/test/data/test-user-agent.jpg?ua=bar") 598 | qs = urlencode(dict(url=url, w=1, h=1)) 599 | resp = self.fetch_error(404, "/?%s" % qs) 600 | 601 | def test_correct_user_agent(self): 602 | url = self.get_url("/test/data/test-user-agent.jpg?ua=%s" % AppUserAgentTest.ua) 603 | qs = urlencode(dict(url=url, w=1, h=1)) 604 | resp = self.fetch_success("/?%s" % qs) 605 | --------------------------------------------------------------------------------