From a3bd2e547ab63dda3ea6c0eed153c470b1382f8b Mon Sep 17 00:00:00 2001 From: Calum Lind Date: Fri, 19 Oct 2018 11:04:27 +0100 Subject: [PATCH] [UI] Use Pillow for .ico icons Pillow added the code in Win32IconImagePlugin to v2.1 (in 2013) so we can remove it. Refactored the tracker_icons code to reflect this change. --- DEPENDS.md | 2 +- deluge/tests/test_tracker_icons.py | 2 + deluge/ui/Win32IconImagePlugin.py | 295 ----------------------------- deluge/ui/tracker_icons.py | 37 ++-- 4 files changed, 20 insertions(+), 316 deletions(-) delete mode 100644 deluge/ui/Win32IconImagePlugin.py diff --git a/DEPENDS.md b/DEPENDS.md index cb587d339..56c451313 100644 --- a/DEPENDS.md +++ b/DEPENDS.md @@ -26,7 +26,7 @@ All modules will require the [common](#common) section dependencies. - [zope.interface] - [chardet] - Optional: Encoding detection. - [setproctitle] - Optional: Renaming processes. -- [Pillow] - Optional: Enable `.ico` support. +- [Pillow] - Optional: Support for resizing tracker icons. - [rencode] _>= 1.0.2_ - Optional: Encoding library (Python port is bundled). - [dbus-python] - Optional: Show item location in filemanager. diff --git a/deluge/tests/test_tracker_icons.py b/deluge/tests/test_tracker_icons.py index b06cb706c..4e7f48c19 100644 --- a/deluge/tests/test_tracker_icons.py +++ b/deluge/tests/test_tracker_icons.py @@ -29,6 +29,8 @@ class TrackerIconsTestCase(BaseTestCase): skip = 'cannot use os.path.samefile to compair on windows(unix only)' def set_up(self): + # Disable resizing with Pillow for consistency. + self.patch(deluge.ui.tracker_icons, 'Image', None) self.icons = TrackerIcons() def tear_down(self): diff --git a/deluge/ui/Win32IconImagePlugin.py b/deluge/ui/Win32IconImagePlugin.py deleted file mode 100644 index 3d720e87b..000000000 --- a/deluge/ui/Win32IconImagePlugin.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright 2008 Bryan Davis -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# $Id$ -"""Alternate PIL plugin for dealing with Microsoft .ico files. Handles XOR -transparency masks, XP style 8bit alpha channels and Vista style PNG image -parts. - ->>> import PIL.Image ->>> import Win32IconImagePlugin ->>> ico = PIL.Image.open('down.ico') ->>> print ico.info['sizes'] -set([(16, 16), (48, 48), (256, 256), (32, 32)]) ->>> ico.size = (16, 16) ->>> ico.show() - -This implementation builds on several samples that I found around the net. -Karsten Hiddemann posted a hint on Image-SIG_ that got me started on this. -Some time later I found a `django snippet`_ by *dc* that I borrowed the -``struct.unpack`` syntax from. I also spent a lot of time looking at the -IcoImagePlugin, BmpImagePlugin, PngImagePlugin and other files from PIL. - -Icon format references: - * http://en.wikipedia.org/wiki/ICO_(file_format) - * http://msdn.microsoft.com/en-us/library/ms997538.aspx - -Example icon to test with `down.ico`_ - -.. _Image-SIG: http://mail.python.org/pipermail/image-sig/2008-May/004986.html -.. _django snippet: http://www.djangosnippets.org/snippets/1287/ -.. _down.ico: http://www.axialis.com/tutorials/iw/down.ico -""" - -from __future__ import division, unicode_literals - -import logging -import struct - -import PIL.BmpImagePlugin -import PIL.Image -import PIL.ImageChops -import PIL.ImageFile -import PIL.PngImagePlugin - -try: - from future_builtins import zip -except ImportError: - # Ignore on Py3. - pass - -_MAGIC = '\0\0\1\0' -log = logging.getLogger(__name__) - - -class Win32IcoFile(object): - """Decoder for Microsoft .ico files.""" - - def __init__(self, buf): - """ - Args: - buf: file-like object containing ico file data - """ - self.buf = buf - self.entry = [] - - header = struct.unpack('<3H', buf.read(6)) - if (0, 1) != header[:2]: - raise SyntaxError('not an ico file') - - self.nb_items = header[2] - - dir_fields = ( - 'width', - 'height', - 'nb_color', - 'reserved', - 'planes', - 'bpp', - 'size', - 'offset', - ) - for i in range(self.nb_items): - directory = list(struct.unpack('<4B2H2I', buf.read(16))) - for j in range(3): - if not directory[j]: - directory[j] = 256 - icon_header = dict(zip(dir_fields, directory)) - icon_header['color_depth'] = icon_header['bpp'] or ( - icon_header['nb_color'] == 16 and 4 - ) - icon_header['dim'] = (icon_header['width'], icon_header['height']) - self.entry.append(icon_header) - # end for (read headers) - - # order by size and color depth - self.entry.sort( - lambda x, y: cmp(x['width'], y['width']) - or cmp(x['color_depth'], y['color_depth']) - ) - self.entry.reverse() - - def sizes(self): - """Get a list of all available icon sizes and color depths.""" - return {(h['width'], h['height']) for h in self.entry} - - def get_image(self, size, bpp=False): - """Get an image from the icon - - Args: - size: tuple of (width, height) - bpp: color depth - """ - for i in range(self.nb_items): - h = self.entry[i] - if size == h['dim'] and (bpp is False or bpp == h['color_depth']): - return self.frame(i) - - return self.frame(0) - - def frame(self, idx): - """ - Get the icon from frame idx - - Args: - idx: Frame index - - Returns: - PIL.Image - """ - header = self.entry[idx] - self.buf.seek(header['offset']) - data = self.buf.read(8) - self.buf.seek(header['offset']) - if data[:8] == PIL.PngImagePlugin._MAGIC: - # png frame - im = PIL.PngImagePlugin.PngImageFile(self.buf) - - else: - # XOR + AND mask bmp frame - im = PIL.BmpImagePlugin.DibImageFile(self.buf) - log.debug('Loaded image: %s %s %s %s', im.format, im.mode, im.size, im.info) - - # change tile dimension to only encompass XOR image - im.size = im.size[0], im.size[1] // 2 - d, e, o, a = im.tile[0] - im.tile[0] = d, (0, 0) + im.size, o, a - - # figure out where AND mask image starts - mode = a[0] - bpp = 8 - for k in PIL.BmpImagePlugin.BIT2MODE: - if mode == PIL.BmpImagePlugin.BIT2MODE[k][1]: - bpp = k - break - # end for - log.debug('o:%s, w:%s, h:%s, bpp:%s', o, im.size[0], im.size[1], bpp) - and_mask_offset = o + (im.size[0] * im.size[1] * (bpp / 8)) - - if bpp == 32: - # 32-bit color depth icon image allows semitransparent areas - # PIL's DIB format ignores transparency bits, recover them - # The DIB is packed in BGRX byte order where X is the alpha channel - - # Back up to start of bmp data - self.buf.seek(o) - # extract every 4th byte (eg. 3,7,11,15,...) - alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4] - - # convert to an 8bpp grayscale image - mask = PIL.Image.frombuffer( - 'L', # 8bpp - im.size, # (w, h) - alpha_bytes, # source chars - 'raw', # raw decoder - ('L', 0, -1), # 8bpp inverted, unpadded, reversed - ) - - # apply mask image as alpha channel - im = im.convert('RGBA') - im.putalpha(mask) - log.debug('image mode: %s', im.mode) - - else: - # get AND image from end of bitmap - w = im.size[0] - if (w % 32) > 0: - # bitmap row data is aligned to word boundaries - w += 32 - (im.size[0] % 32) - # the total mask data is padded row size * height / bits per char - total_bytes = (w * im.size[1]) // 8 - log.debug( - 'tot=%d, off=%d, w=%d, size=%d', - len(data), - and_mask_offset, - w, - total_bytes, - ) - - self.buf.seek(and_mask_offset) - mask_data = self.buf.read(total_bytes) - - # convert raw data to image - mask = PIL.Image.frombuffer( - '1', # 1 bpp - im.size, # (w, h) - mask_data, # source chars - 'raw', # raw decoder - ('1;I', w // 8, -1), # 1bpp inverted, padded, reversed - ) - - # now we have two images, im is XOR image and mask is AND image - # set mask as alpha channel - im = im.convert('RGBA') - im.putalpha(mask) - log.debug('image mode: %s', im.mode) - # end if !'RGBA' - # end if (png)/else(bmp) - - return im - - # end frame - - def __repr__(self): - s = 'Microsoft Icon: %d images (max %dx%d %dbpp)' % ( - len(self.entry), - self.entry[0]['width'], - self.entry[0]['height'], - self.entry[0]['bpp'], - ) - return s - - -# end Win32IcoFile - - -class Win32IconImageFile(PIL.ImageFile.ImageFile): # pylint: disable=abstract-method - """ - PIL read-only image support for Microsoft .ico files. - - By default the largest resolution image in the file will be loaded. This can - be changed by altering the 'size' attribute before calling 'load'. - - The info dictionary has a key 'sizes' that is a list of the sizes available - in the icon file. - - Handles classic, XP and Vista icon formats. - """ - - format = 'ICO' - format_description = 'Microsoft icon' - - def _open(self): - self.ico = Win32IcoFile(self.fp) - self.info['sizes'] = self.ico.sizes() - self.size = self.ico.entry[0]['dim'] - self.load() - - def load(self): - im = self.ico.get_image(self.size) - # if tile is PNG, it won't really be loaded yet - im.load() - self.im = im.im - self.mode = im.mode - self.size = im.size - - -# end class Win32IconImageFile - - -def _accept(prefix): - """ - Quick file test helper for Image.open() - """ - return prefix[:4] == _MAGIC - - -# end _accept - - -# register our decoder with PIL -PIL.Image.register_open(Win32IconImageFile.format, Win32IconImageFile, _accept) -PIL.Image.register_extension(Win32IconImageFile.format, '.ico') diff --git a/deluge/ui/tracker_icons.py b/deluge/ui/tracker_icons.py index 48c742f77..c10cd2f8e 100644 --- a/deluge/ui/tracker_icons.py +++ b/deluge/ui/tracker_icons.py @@ -31,13 +31,9 @@ except ImportError: from urlparse import urljoin, urlparse # pylint: disable=ungrouped-imports try: - import PIL.Image as Image + from PIL import Image except ImportError: - PIL_INSTALLED = False -else: - import deluge.ui.Win32IconImagePlugin # NOQA pylint: disable=unused-import, ungrouped-imports - - PIL_INSTALLED = True + Image = None log = logging.getLogger(__name__) @@ -229,8 +225,7 @@ class TrackerIcons(Component): callbackArgs=(host,), errbackArgs=(host,), ) - if PIL_INSTALLED: - d.addCallback(self.resize_icon) + d.addCallback(self.resize_icon) d.addCallback(self.store_icon, host) return d @@ -383,13 +378,14 @@ class TrackerIcons(Component): :raises: InvalidIconError """ - if PIL_INSTALLED: + if Image: try: - Image.open(icon_name) + with Image.open(icon_name): + pass except IOError as ex: raise InvalidIconError(ex) else: - if os.stat(icon_name).st_size == 0: + if not os.path.getsize(icon_name): raise InvalidIconError('empty icon') return icon_name @@ -478,16 +474,17 @@ class TrackerIcons(Component): :returns: the resized icon :rtype: TrackerIcon """ - if icon: + # Requires Pillow(PIL) to resize. + if icon and Image: filename = icon.get_filename() - img = Image.open(filename) - if img.size > (16, 16): - new_filename = filename.rpartition('.')[0] + '.png' - img = img.resize((16, 16), Image.ANTIALIAS) - img.save(new_filename) - if new_filename != filename: - os.remove(filename) - icon = TrackerIcon(new_filename) + with Image.open(filename) as img: + if img.size > (16, 16): + new_filename = filename.rpartition('.')[0] + '.png' + img = img.resize((16, 16), Image.ANTIALIAS) + img.save(new_filename) + if new_filename != filename: + os.remove(filename) + icon = TrackerIcon(new_filename) return icon def store_icon(self, icon, host):