Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow ICNS save on all operating systems #4526

Merged
merged 27 commits into from Jun 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b685c18
Save in icns format to support all operating systems.
baletu Apr 4, 2020
4500acb
Lint fixes
radarhere Apr 4, 2020
5ef382f
Test all operating systems
radarhere Apr 4, 2020
ea98bb6
Restored append_images
radarhere Apr 4, 2020
cc1e78c
Remove unused packages
baletu Apr 7, 2020
10669b1
Merge pull request #1 from radarhere/icns
newpanjing Apr 7, 2020
a618e2c
Fixed big-endian saving
radarhere Apr 7, 2020
65b735d
Marked to_int() as private
radarhere Apr 7, 2020
a283102
ICNS can now be saved on non-macOS platforms [ci skip]
radarhere Apr 7, 2020
84b7e26
Merged HEADER_SIZE into HEADERSIZE
radarhere Apr 7, 2020
9e9e136
Do not save two temporary files for the same size
radarhere Apr 7, 2020
8c37960
Merge remote-tracking branch 'radarhere/icns'
radarhere May 11, 2020
b214c2d
Changed wording
radarhere Nov 1, 2020
63e8420
Removed docstring sentence
radarhere Nov 1, 2020
d7245ea
Capitalisation
radarhere Nov 1, 2020
0ff800a
Updated docstring
radarhere Dec 30, 2020
86ad435
Merge branch 'master' into master
radarhere Dec 30, 2020
800a265
Test ICNS on all operating systems
radarhere Dec 30, 2020
70fafe3
Update version number
hugovk Jun 28, 2021
38d45d2
flush if hasattr
hugovk Jun 28, 2021
8e60ca6
Use bytes
radarhere Jun 29, 2021
f5558f4
Only getvalue() once per entry
radarhere Jun 29, 2021
d07a085
Simplified use of struct
radarhere Jun 29, 2021
f366330
Only open one BytesIO instance at a time
radarhere Jun 29, 2021
90ece13
Merge branch 'master' into master
radarhere Jun 29, 2021
8736a74
Removed _to_int
radarhere Jun 29, 2021
43f5a5f
Combined sizes and types into dictionary
radarhere Jun 30, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 0 additions & 4 deletions Tests/test_file_icns.py
@@ -1,5 +1,4 @@
import io
import sys

import pytest

Expand Down Expand Up @@ -28,7 +27,6 @@ def test_sanity():
assert im.format == "ICNS"


@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS")
def test_save(tmp_path):
temp_file = str(tmp_path / "temp.icns")

Expand All @@ -41,7 +39,6 @@ def test_save(tmp_path):
assert reread.format == "ICNS"


@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS")
def test_save_append_images(tmp_path):
temp_file = str(tmp_path / "temp.icns")
provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128))
Expand All @@ -57,7 +54,6 @@ def test_save_append_images(tmp_path):
assert_image_equal(reread, provided_im)


@pytest.mark.skipif(sys.platform != "darwin", reason="Requires macOS")
def test_save_fp():
fp = io.BytesIO()

Expand Down
6 changes: 5 additions & 1 deletion docs/handbook/image-file-formats.rst
Expand Up @@ -215,12 +215,16 @@ attributes before loading the file::
ICNS
^^^^

Pillow reads and (macOS only) writes macOS ``.icns`` files. By default, the
Pillow reads and writes macOS ``.icns`` files. By default, the
largest available icon is read, though you can override this by setting the
:py:attr:`~PIL.Image.Image.size` property before calling
:py:meth:`~PIL.Image.Image.load`. The :py:meth:`~PIL.Image.open` method
sets the following :py:attr:`~PIL.Image.Image.info` property:

.. note::

Prior to version 8.3.0, Pillow could only write ICNS files on macOS.

**sizes**
A list of supported sizes found in this icon file; these are a
3-tuple, ``(width, height, scale)``, where ``scale`` is 2 for a retina
Expand Down
103 changes: 50 additions & 53 deletions src/PIL/IcnsImagePlugin.py
Expand Up @@ -6,29 +6,29 @@
#
# history:
# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies.
# 2020-04-04 Allow saving on all operating systems.
#
# Copyright (c) 2004 by Bob Ippolito.
# Copyright (c) 2004 by Secret Labs.
# Copyright (c) 2004 by Fredrik Lundh.
# Copyright (c) 2014 by Alastair Houghton.
# Copyright (c) 2020 by Pan Jing.
#
# See the README file for information on usage and redistribution.
#

import io
import os
import shutil
import struct
import subprocess
import sys
import tempfile

from PIL import Image, ImageFile, PngImagePlugin, features

enable_jpeg2k = features.check_codec("jpg_2000")
if enable_jpeg2k:
from PIL import Jpeg2KImagePlugin

MAGIC = b"icns"
HEADERSIZE = 8


Expand Down Expand Up @@ -167,7 +167,7 @@ def __init__(self, fobj):
self.dct = dct = {}
self.fobj = fobj
sig, filesize = nextheader(fobj)
if sig != b"icns":
if sig != MAGIC:
raise SyntaxError("not an icns file")
i = HEADERSIZE
while i < filesize:
Expand Down Expand Up @@ -306,74 +306,71 @@ def load(self):
def _save(im, fp, filename):
"""
Saves the image as a series of PNG files,
that are then converted to a .icns file
using the macOS command line utility 'iconutil'.

macOS only.
that are then combined into a .icns file.
"""
if hasattr(fp, "flush"):
fp.flush()

# create the temporary set of pngs
with tempfile.TemporaryDirectory(".iconset") as iconset:
provided_images = {
im.width: im for im in im.encoderinfo.get("append_images", [])
}
last_w = None
second_path = None
for w in [16, 32, 128, 256, 512]:
prefix = f"icon_{w}x{w}"

first_path = os.path.join(iconset, prefix + ".png")
if last_w == w:
shutil.copyfile(second_path, first_path)
else:
im_w = provided_images.get(w, im.resize((w, w), Image.LANCZOS))
im_w.save(first_path)

second_path = os.path.join(iconset, prefix + "@2x.png")
im_w2 = provided_images.get(w * 2, im.resize((w * 2, w * 2), Image.LANCZOS))
im_w2.save(second_path)
last_w = w * 2

# iconutil -c icns -o {} {}

fp_only = not filename
if fp_only:
f, filename = tempfile.mkstemp(".icns")
os.close(f)
convert_cmd = ["iconutil", "-c", "icns", "-o", filename, iconset]
convert_proc = subprocess.Popen(
convert_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
sizes = {
b"ic07": 128,
b"ic08": 256,
b"ic09": 512,
b"ic10": 1024,
b"ic11": 32,
b"ic12": 64,
b"ic13": 256,
b"ic14": 512,
}
provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])}
size_streams = {}
for size in set(sizes.values()):
image = (
provided_images[size]
if size in provided_images
else im.resize((size, size))
)

convert_proc.stdout.close()
temp = io.BytesIO()
image.save(temp, "png")
size_streams[size] = temp.getvalue()

entries = []
for type, size in sizes.items():
stream = size_streams[size]
entries.append({"type": type, "size": len(stream), "stream": stream})

retcode = convert_proc.wait()
# Header
fp.write(MAGIC)
fp.write(struct.pack(">i", sum(entry["size"] for entry in entries)))

if retcode:
raise subprocess.CalledProcessError(retcode, convert_cmd)
# TOC
fp.write(b"TOC ")
fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
for entry in entries:
fp.write(entry["type"])
fp.write(struct.pack(">i", HEADERSIZE + entry["size"]))

if fp_only:
with open(filename, "rb") as f:
fp.write(f.read())
# Data
for entry in entries:
fp.write(entry["type"])
fp.write(struct.pack(">i", HEADERSIZE + entry["size"]))
fp.write(entry["stream"])

if hasattr(fp, "flush"):
fp.flush()


def _accept(prefix):
return prefix[:4] == b"icns"
return prefix[:4] == MAGIC


Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
Image.register_extension(IcnsImageFile.format, ".icns")

if sys.platform == "darwin":
Image.register_save(IcnsImageFile.format, _save)

Image.register_mime(IcnsImageFile.format, "image/icns")

Image.register_save(IcnsImageFile.format, _save)
Image.register_mime(IcnsImageFile.format, "image/icns")

if __name__ == "__main__":

if len(sys.argv) < 2:
print("Syntax: python3 IcnsImagePlugin.py [file]")
sys.exit()
Expand Down