Skip to content

Commit

Permalink
Add support for ICO images (#141)
Browse files Browse the repository at this point in the history
Especially useful when dynamically creating favicons.

Note: `wagtail.ico` is taken from wagtail.org.
  • Loading branch information
RealOrangeOne authored Jan 17, 2024
1 parent 9565d94 commit dc30528
Show file tree
Hide file tree
Showing 11 changed files with 102 additions and 8 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ A wrapper that combines the functionality of multiple Python image libraries int

## Overview

Willow is a simple image library that combines the APIs of [Pillow](https://pillow.readthedocs.io/), [Wand](https://docs.wand-py.org) and [OpenCV](https://opencv.org/).
Willow is a simple image library that combines the APIs of [Pillow](https://pillow.readthedocs.io/), [Wand](https://docs.wand-py.org) and [OpenCV](https://opencv.org/).
It converts the image between the libraries when necessary.

Willow currently has basic resize and crop operations, face and feature detection and animated GIF support.
Willow currently has basic resize and crop operations, face and feature detection and animated GIF support.
New operations and library integrations can also be [easily implemented](https://willow.readthedocs.org/en/latest/guide/extend.html).

The library is written in pure Python and supports versions 3.8 3.9, 3.10, 3.11 and 3.12.
Expand Down Expand Up @@ -77,6 +77,7 @@ As neither Pillow nor Wand support detecting faces, Willow would automatically c
| `save_as_webp(file, quality)` ||| |
| `save_as_heif(file, quality, lossless)` | ✓⁺ | | |
| `save_as_avif(file, quality, lossless)` | ✓⁺ | ✓⁺ | |
| `save_as_ico(file)` ||| |
| `has_alpha()` |||\* |
| `has_animation()` |\* ||\* |
| `get_pillow_image()` || | |
Expand Down
13 changes: 7 additions & 6 deletions docs/guide/save.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ In Willow there are separate save operations for each image format:
- :meth:`~Image.save_as_svg`
- :meth:`~Image.save_as_heic`
- :meth:`~Image.save_as_avif`
- :meth:`~Image.save_as_ico`


All three take one positional argument, the file-like object to write the image
Expand All @@ -25,10 +26,10 @@ For example, to save an image as a PNG file:
Changing the quality setting
---------------------------------

:meth:`~Image.save_as_jpeg` and :meth:`~Image.save_as_webp` takes a ``quality``
keyword argument, which is a number between 1 and 100. It defaults to 85
for :meth:`~Image.save_as_jpeg` and 80 for :meth:`~Image.save_as_webp`.
Decreasing this number will decrease the output file size at the cost
:meth:`~Image.save_as_jpeg` and :meth:`~Image.save_as_webp` takes a ``quality``
keyword argument, which is a number between 1 and 100. It defaults to 85
for :meth:`~Image.save_as_jpeg` and 80 for :meth:`~Image.save_as_webp`.
Decreasing this number will decrease the output file size at the cost
of losing image quality.

For example, to save an image with low quality:
Expand Down Expand Up @@ -60,10 +61,10 @@ You can encode the image to AVIF, HEIC (Pillow-only) and WebP without any loss b
with open('lossless.avif', 'wb') as f:
i.save_as_avif(f, lossless=True)
with open('lossless.heic', 'wb') as f:
i.save_as_heic(f, lossless=True)
with open('lossless.webp', 'wb') as f:
i.save_as_webp(f, lossless=True)
Expand Down
12 changes: 12 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,18 @@ Here's a full list of operations provided by Willow out of the box:
image.save_as_svg(f)
.. method:: save_as_ico(file)

Saves the image to the specified file-like object in ICO format.

returns a ``IcoImageFile`` wrapping the file.

.. code-block:: python
with open('out.ico', 'w') as f:
image.save_as_ico(f)
.. method:: get_pillow_image()

(Pillow only)
Expand Down
Binary file added tests/images/wagtail.ico
Binary file not shown.
21 changes: 21 additions & 0 deletions tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
Image,
ImageFile,
JPEGImageFile,
Expand Down Expand Up @@ -192,6 +193,16 @@ def test_avif(self):
self.assertEqual(height, 241)
self.assertEqual(image.mime_type, "image/avif")

def test_ico(self):
with open("tests/images/wagtail.ico", "rb") as f:
image = Image.open(f)
width, height = image.get_size()

self.assertIsInstance(image, IcoImageFile)
self.assertEqual(width, 48)
self.assertEqual(height, 48)
self.assertEqual(image.mime_type, "image/x-icon")


class TestSaveImage(unittest.TestCase):
"""
Expand Down Expand Up @@ -227,6 +238,16 @@ def test_save_as_avif(self):
self.assertIsInstance(image, AvifImageFile)
self.assertEqual(image.mime_type, "image/avif")

def test_save_as_ico(self):
with open("tests/images/sails.bmp", "rb") as f:
image = Image.open(f)
buf = io.BytesIO()
image.save("ico", buf)
buf.seek(0)
image = Image.open(buf)
self.assertIsInstance(image, IcoImageFile)
self.assertEqual(image.mime_type, "image/x-icon")

def test_save_as_foo(self):
image = Image()
image.save_as_jpeg = mock.MagicMock()
Expand Down
17 changes: 17 additions & 0 deletions tests/test_pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AvifImageFile,
BadImageOperationError,
GIFImageFile,
IcoImageFile,
JPEGImageFile,
PNGImageFile,
WebPImageFile,
Expand Down Expand Up @@ -491,6 +492,22 @@ def test_save_avif_lossless(self):
diff = ImageChops.difference(original_image, lossless_image)
self.assertIsNone(diff.getbbox())

def test_save_ico(self):
output = io.BytesIO()
return_value = self.image.save_as_ico(output)
output.seek(0)

self.assertEqual(filetype.guess_extension(output), "ico")
self.assertIsInstance(return_value, IcoImageFile)
self.assertEqual(return_value.f, output)

def test_open_ico(self):
with open("tests/images/wagtail.ico", "rb") as f:
image = PillowImage.open(IcoImageFile(f))

self.assertTrue(image.has_alpha())
self.assertFalse(image.has_animation())


class TestPillowImageWithOptimizers(unittest.TestCase):
def setUp(self):
Expand Down
10 changes: 10 additions & 0 deletions tests/test_wand.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AvifImageFile,
BadImageOperationError,
GIFImageFile,
IcoImageFile,
JPEGImageFile,
PNGImageFile,
WebPImageFile,
Expand Down Expand Up @@ -422,6 +423,15 @@ def test_save_as_webp_with_icc_profile(self):
saved_icc_profile = saved.get_icc_profile()
self.assertEqual(saved_icc_profile, icc_profile)

def test_save_as_ico(self):
output = io.BytesIO()
return_value = self.image.save_as_ico(output)
output.seek(0)

self.assertEqual(filetype.guess_extension(output), "ico")
self.assertIsInstance(return_value, IcoImageFile)
self.assertEqual(return_value.f, output)


class TestWandImageWithOptimizers(unittest.TestCase):
def setUp(self):
Expand Down
2 changes: 2 additions & 0 deletions willow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def setup():
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
JPEGImageFile,
PNGImageFile,
RGBAImageBuffer,
Expand All @@ -34,6 +35,7 @@ def setup():
registry.register_image_class(SvgImageFile)
registry.register_image_class(SvgImage)
registry.register_image_class(AvifImageFile)
registry.register_image_class(IcoImageFile)

registry.register_plugin(pillow)
registry.register_plugin(wand)
Expand Down
7 changes: 7 additions & 0 deletions willow/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def save(
"svg",
"heic",
"avif",
"ico",
]:
raise ValueError("Unknown image format: %s" % image_format)

Expand Down Expand Up @@ -337,6 +338,11 @@ def mime_type(self):
return "image/avif"


class IcoImageFile(ImageFile):
format_name = "ico"
mime_type = "image/x-icon"


INITIAL_IMAGE_CLASSES = {
# A mapping of image formats to their initial class
image_types.Jpeg().extension: JPEGImageFile,
Expand All @@ -348,4 +354,5 @@ def mime_type(self):
"svg": SvgImageFile,
image_types.Heic().extension: HeicImageFile,
image_types.Avif().extension: AvifImageFile,
image_types.Ico().extension: IcoImageFile,
}
11 changes: 11 additions & 0 deletions willow/plugins/pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
Image,
JPEGImageFile,
PNGImageFile,
Expand Down Expand Up @@ -450,6 +451,15 @@ def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):

return AvifImageFile(f)

@Image.operation
def save_as_ico(self, f, apply_optimizers=True):
self.image.save(f, "ICO")

if apply_optimizers:
self.optimize(f, "ico")

return IcoImageFile(f)

@Image.operation
def auto_orient(self):
# JPEG files can be orientated using an EXIF tag.
Expand All @@ -473,6 +483,7 @@ def get_pillow_image(self):
@Image.converter_from(WebPImageFile)
@Image.converter_from(HeicImageFile)
@Image.converter_from(AvifImageFile)
@Image.converter_from(IcoImageFile)
def open(cls, image_file):
image_file.f.seek(0)
image = _PIL_Image().open(image_file.f)
Expand Down
12 changes: 12 additions & 0 deletions willow/plugins/wand.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
BMPImageFile,
GIFImageFile,
HeicImageFile,
IcoImageFile,
Image,
JPEGImageFile,
PNGImageFile,
Expand Down Expand Up @@ -277,6 +278,16 @@ def save_as_avif(self, f, quality=80, lossless=False, apply_optimizers=True):

return AvifImageFile(f)

@Image.operation
def save_as_ico(self, f, apply_optimizers=True):
with self.image.convert("ico") as converted:
converted.save(file=f)

if apply_optimizers:
self.optimize(f, "ico")

return IcoImageFile(f)

@Image.operation
def auto_orient(self):
image = self.image
Expand Down Expand Up @@ -325,6 +336,7 @@ def get_wand_image(self):
@Image.converter_from(WebPImageFile, cost=150)
@Image.converter_from(HeicImageFile, cost=150)
@Image.converter_from(AvifImageFile, cost=150)
@Image.converter_from(IcoImageFile, cost=150)
def open(cls, image_file):
image_file.f.seek(0)
image = _wand_image().Image(file=image_file.f)
Expand Down

0 comments on commit dc30528

Please sign in to comment.