| OLD | NEW |
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
| 2 # Copyright 2015 The Chromium Authors. All rights reserved. | 2 # Copyright 2015 The Chromium Authors. All rights reserved. |
| 3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 | 5 |
| 6 """Windows ICO file crusher. | 6 """Windows ICO file crusher. |
| 7 | 7 |
| 8 Optimizes the PNG images within a Windows ICO icon file. This extracts all of | 8 Optimizes the PNG images within a Windows ICO icon file. This extracts all of |
| 9 the sub-images within the file, runs any PNG-formatted images through | 9 the sub-images within the file, runs any PNG-formatted images through |
| 10 optimize-png-files.sh, then packs them back into an ICO file. | 10 optimize-png-files.sh, then packs them back into an ICO file. |
| 11 | 11 |
| 12 NOTE: ICO files can contain both raw uncompressed BMP files and PNG files. This | 12 NOTE: ICO files can contain both raw uncompressed BMP files and PNG files. This |
| 13 script does not touch the BMP files, which means if you have a huge uncompressed | 13 script does not touch the BMP files, which means if you have a huge uncompressed |
| 14 image, it will not get smaller. 256x256 icons should be PNG-formatted first. | 14 image, it will not get smaller. 256x256 icons should be PNG-formatted first. |
| 15 (Smaller icons should be BMPs for compatibility with Windows XP.) | 15 (Smaller icons should be BMPs for compatibility with Windows XP.) |
| 16 """ | 16 """ |
| 17 | 17 |
| 18 import argparse | 18 import argparse |
| 19 import logging | 19 import logging |
| 20 import math | |
| 21 import os | 20 import os |
| 22 import StringIO | 21 import StringIO |
| 23 import struct | |
| 24 import subprocess | |
| 25 import sys | 22 import sys |
| 26 import tempfile | |
| 27 | 23 |
| 28 OPTIMIZE_PNG_FILES = 'tools/resources/optimize-png-files.sh' | 24 import ico_tools |
| 29 | |
| 30 logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') | |
| 31 | |
| 32 class InvalidFile(Exception): | |
| 33 """Represents an invalid ICO file.""" | |
| 34 | |
| 35 def IsPng(png_data): | |
| 36 """Determines whether a sequence of bytes is a PNG.""" | |
| 37 return png_data.startswith('\x89PNG\r\n\x1a\n') | |
| 38 | |
| 39 def OptimizePngFile(temp_dir, png_filename, optimization_level=None): | |
| 40 """Optimize a PNG file. | |
| 41 | |
| 42 Args: | |
| 43 temp_dir: The directory containing the PNG file. Must be the only file in | |
| 44 the directory. | |
| 45 png_filename: The full path to the PNG file to optimize. | |
| 46 | |
| 47 Returns: | |
| 48 The raw bytes of a PNG file, an optimized version of the input. | |
| 49 """ | |
| 50 logging.debug('Crushing PNG image...') | |
| 51 args = [OPTIMIZE_PNG_FILES] | |
| 52 if optimization_level is not None: | |
| 53 args.append('-o%d' % optimization_level) | |
| 54 args.append(temp_dir) | |
| 55 result = subprocess.call(args, stdout=sys.stderr) | |
| 56 if result != 0: | |
| 57 logging.warning('Warning: optimize-png-files failed (%d)', result) | |
| 58 else: | |
| 59 logging.debug('optimize-png-files succeeded') | |
| 60 | |
| 61 with open(png_filename, 'rb') as png_file: | |
| 62 return png_file.read() | |
| 63 | |
| 64 def OptimizePng(png_data, optimization_level=None): | |
| 65 """Optimize a PNG. | |
| 66 | |
| 67 Args: | |
| 68 png_data: The raw bytes of a PNG file. | |
| 69 | |
| 70 Returns: | |
| 71 The raw bytes of a PNG file, an optimized version of the input. | |
| 72 """ | |
| 73 temp_dir = tempfile.mkdtemp() | |
| 74 try: | |
| 75 logging.debug('temp_dir = %s', temp_dir) | |
| 76 png_filename = os.path.join(temp_dir, 'image.png') | |
| 77 with open(png_filename, 'wb') as png_file: | |
| 78 png_file.write(png_data) | |
| 79 return OptimizePngFile(temp_dir, png_filename, | |
| 80 optimization_level=optimization_level) | |
| 81 | |
| 82 finally: | |
| 83 if os.path.exists(png_filename): | |
| 84 os.unlink(png_filename) | |
| 85 os.rmdir(temp_dir) | |
| 86 | |
| 87 def ComputeANDMaskFromAlpha(image_data, width, height): | |
| 88 """Compute an AND mask from 32-bit BGRA image data.""" | |
| 89 and_bytes = [] | |
| 90 for y in range(height): | |
| 91 bit_count = 0 | |
| 92 current_byte = 0 | |
| 93 for x in range(width): | |
| 94 alpha = image_data[(y * width + x) * 4 + 3] | |
| 95 current_byte <<= 1 | |
| 96 if ord(alpha) == 0: | |
| 97 current_byte |= 1 | |
| 98 bit_count += 1 | |
| 99 if bit_count == 8: | |
| 100 and_bytes.append(current_byte) | |
| 101 bit_count = 0 | |
| 102 current_byte = 0 | |
| 103 | |
| 104 # At the end of a row, pad the current byte. | |
| 105 if bit_count > 0: | |
| 106 current_byte <<= (8 - bit_count) | |
| 107 and_bytes.append(current_byte) | |
| 108 # And keep padding until a multiple of 4 bytes. | |
| 109 while len(and_bytes) % 4 != 0: | |
| 110 and_bytes.append(0) | |
| 111 | |
| 112 and_bytes = ''.join(map(chr, and_bytes)) | |
| 113 return and_bytes | |
| 114 | |
| 115 def RebuildANDMask(iconimage): | |
| 116 """Rebuild the AND mask in an icon image. | |
| 117 | |
| 118 GIMP (<=2.8.14) creates a bad AND mask on 32-bit icon images (pixels with <50% | |
| 119 opacity are marked as transparent, which end up looking black on Windows). So, | |
| 120 if this is a 32-bit image, throw the mask away and recompute it from the alpha | |
| 121 data. (See: https://bugzilla.gnome.org/show_bug.cgi?id=755200) | |
| 122 | |
| 123 Args: | |
| 124 iconimage: Bytes of an icon image (the BMP data for an entry in an ICO | |
| 125 file). Must be in BMP format, not PNG. Does not need to be 32-bit (if it | |
| 126 is not 32-bit, this is a no-op). | |
| 127 | |
| 128 Returns: | |
| 129 An updated |iconimage|, with the AND mask re-computed using | |
| 130 ComputeANDMaskFromAlpha. | |
| 131 """ | |
| 132 # Parse BITMAPINFOHEADER. | |
| 133 (_, width, height, _, bpp, _, _, _, _, num_colors, _) = struct.unpack( | |
| 134 '<LLLHHLLLLLL', iconimage[:40]) | |
| 135 | |
| 136 if bpp != 32: | |
| 137 # No alpha channel, so the mask cannot be "wrong" (it is the only source of | |
| 138 # transparency information). | |
| 139 return iconimage | |
| 140 | |
| 141 height /= 2 | |
| 142 xor_size = int(math.ceil(width * bpp / 32.0)) * 4 * height | |
| 143 | |
| 144 # num_colors can be 0, implying 2^bpp colors. | |
| 145 xor_palette_size = (num_colors or (1 << bpp if bpp < 24 else 0)) * 4 | |
| 146 xor_data = iconimage[40 + xor_palette_size : | |
| 147 40 + xor_palette_size + xor_size] | |
| 148 | |
| 149 and_data = ComputeANDMaskFromAlpha(xor_data, width, height) | |
| 150 | |
| 151 # Replace the AND mask in the original icon data. | |
| 152 return iconimage[:40 + xor_palette_size + xor_size] + and_data | |
| 153 | |
| 154 def OptimizeIcoFile(infile, outfile, optimization_level=None): | |
| 155 """Read an ICO file, optimize its PNGs, and write the output to outfile. | |
| 156 | |
| 157 Args: | |
| 158 infile: The file to read from. Must be a seekable file-like object | |
| 159 containing a Microsoft ICO file. | |
| 160 outfile: The file to write to. | |
| 161 """ | |
| 162 filename = os.path.basename(infile.name) | |
| 163 icondir = infile.read(6) | |
| 164 zero, image_type, num_images = struct.unpack('<HHH', icondir) | |
| 165 if zero != 0: | |
| 166 raise InvalidFile('First word must be 0.') | |
| 167 if image_type not in (1, 2): | |
| 168 raise InvalidFile('Image type must be 1 or 2.') | |
| 169 | |
| 170 # Read and unpack each ICONDIRENTRY. | |
| 171 icon_dir_entries = [] | |
| 172 for i in range(num_images): | |
| 173 icondirentry = infile.read(16) | |
| 174 icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) | |
| 175 | |
| 176 # Read each icon's bitmap data, crush PNGs, and update icon dir entries. | |
| 177 current_offset = infile.tell() | |
| 178 icon_bitmap_data = [] | |
| 179 for i in range(num_images): | |
| 180 width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] | |
| 181 width = width or 256 | |
| 182 height = height or 256 | |
| 183 offset = current_offset | |
| 184 icon_data = infile.read(size) | |
| 185 if len(icon_data) != size: | |
| 186 raise EOFError() | |
| 187 | |
| 188 entry_is_png = IsPng(icon_data) | |
| 189 logging.info('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, | |
| 190 height, size, 'PNG' if entry_is_png else 'BMP') | |
| 191 | |
| 192 if entry_is_png: | |
| 193 icon_data = OptimizePng(icon_data, optimization_level=optimization_level) | |
| 194 else: | |
| 195 new_icon_data = RebuildANDMask(icon_data) | |
| 196 if new_icon_data != icon_data: | |
| 197 logging.info(' * Rebuilt AND mask for this image from alpha channel.') | |
| 198 icon_data = new_icon_data | |
| 199 | |
| 200 if width >= 256 or height >= 256: | |
| 201 # TODO(mgiuca): Automatically convert large BMP images to PNGs. | |
| 202 logging.warning('Entry #%d is a large image in uncompressed BMP ' | |
| 203 'format. Please manually convert to PNG format before ' | |
| 204 'running this utility.', i + 1) | |
| 205 | |
| 206 new_size = len(icon_data) | |
| 207 current_offset += new_size | |
| 208 icon_dir_entries[i] = (width % 256, height % 256, num_colors, r1, r2, r3, | |
| 209 new_size, offset) | |
| 210 icon_bitmap_data.append(icon_data) | |
| 211 | |
| 212 # Write the data back to outfile. | |
| 213 outfile.write(icondir) | |
| 214 for icon_dir_entry in icon_dir_entries: | |
| 215 outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) | |
| 216 for icon_bitmap in icon_bitmap_data: | |
| 217 outfile.write(icon_bitmap) | |
| 218 | 25 |
| 219 def main(args=None): | 26 def main(args=None): |
| 220 if args is None: | 27 if args is None: |
| 221 args = sys.argv[1:] | 28 args = sys.argv[1:] |
| 222 | 29 |
| 223 parser = argparse.ArgumentParser(description='Crush Windows ICO files.') | 30 parser = argparse.ArgumentParser(description='Crush Windows ICO files.') |
| 224 parser.add_argument('files', metavar='ICO', type=argparse.FileType('r+b'), | 31 parser.add_argument('files', metavar='ICO', type=argparse.FileType('r+b'), |
| 225 nargs='+', help='.ico files to be crushed') | 32 nargs='+', help='.ico files to be crushed') |
| 226 parser.add_argument('-o', dest='optimization_level', metavar='OPT', type=int, | 33 parser.add_argument('-o', dest='optimization_level', metavar='OPT', type=int, |
| 227 help='optimization level') | 34 help='optimization level') |
| 228 parser.add_argument('-d', '--debug', dest='debug', action='store_true', | 35 parser.add_argument('-d', '--debug', dest='debug', action='store_true', |
| 229 help='enable debug logging') | 36 help='enable debug logging') |
| 230 | 37 |
| 231 args = parser.parse_args() | 38 args = parser.parse_args() |
| 232 | 39 |
| 233 if args.debug: | 40 if args.debug: |
| 234 logging.getLogger().setLevel(logging.DEBUG) | 41 logging.getLogger().setLevel(logging.DEBUG) |
| 235 | 42 |
| 236 for file in args.files: | 43 for file in args.files: |
| 237 buf = StringIO.StringIO() | 44 buf = StringIO.StringIO() |
| 238 file.seek(0, os.SEEK_END) | 45 file.seek(0, os.SEEK_END) |
| 239 old_length = file.tell() | 46 old_length = file.tell() |
| 240 file.seek(0, os.SEEK_SET) | 47 file.seek(0, os.SEEK_SET) |
| 241 OptimizeIcoFile(file, buf, args.optimization_level) | 48 ico_tools.OptimizeIcoFile(file, buf, args.optimization_level) |
| 242 | 49 |
| 243 new_length = len(buf.getvalue()) | 50 new_length = len(buf.getvalue()) |
| 244 | 51 |
| 245 # Always write (even if file size not reduced), because we make other fixes | 52 # Always write (even if file size not reduced), because we make other fixes |
| 246 # such as regenerating the AND mask. | 53 # such as regenerating the AND mask. |
| 247 file.truncate(new_length) | 54 file.truncate(new_length) |
| 248 file.seek(0) | 55 file.seek(0) |
| 249 file.write(buf.getvalue()) | 56 file.write(buf.getvalue()) |
| 250 | 57 |
| 251 if new_length >= old_length: | 58 if new_length >= old_length: |
| 252 logging.info('%s : Could not reduce file size.', file.name) | 59 logging.info('%s : Could not reduce file size.', file.name) |
| 253 else: | 60 else: |
| 254 saving = old_length - new_length | 61 saving = old_length - new_length |
| 255 saving_percent = float(saving) / old_length | 62 saving_percent = float(saving) / old_length |
| 256 logging.info('%s : %d => %d (%d bytes : %d %%)', file.name, old_length, | 63 logging.info('%s : %d => %d (%d bytes : %d %%)', file.name, old_length, |
| 257 new_length, saving, int(saving_percent * 100)) | 64 new_length, saving, int(saving_percent * 100)) |
| 258 | 65 |
| 259 if __name__ == '__main__': | 66 if __name__ == '__main__': |
| 260 sys.exit(main()) | 67 sys.exit(main()) |
| OLD | NEW |