| OLD | NEW |
| (Empty) | |
| 1 #!/usr/bin/env python |
| 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 |
| 4 # found in the LICENSE file. |
| 5 |
| 6 """Windows ICO file crusher. |
| 7 |
| 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 |
| 10 optimize-png-files.sh, then packs them back into an ICO file. |
| 11 |
| 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 |
| 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.) |
| 16 """ |
| 17 |
| 18 import argparse |
| 19 import logging |
| 20 import os |
| 21 import StringIO |
| 22 import struct |
| 23 import subprocess |
| 24 import sys |
| 25 import tempfile |
| 26 |
| 27 OPTIMIZE_PNG_FILES = 'tools/resources/optimize-png-files.sh' |
| 28 |
| 29 logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') |
| 30 |
| 31 class InvalidFile(Exception): |
| 32 """Represents an invalid ICO file.""" |
| 33 |
| 34 def IsPng(png_data): |
| 35 """Determines whether a sequence of bytes is a PNG.""" |
| 36 return png_data.startswith('\x89PNG\r\n\x1a\n') |
| 37 |
| 38 def OptimizePngFile(temp_dir, png_filename, optimization_level=None): |
| 39 """Optimize a PNG file. |
| 40 |
| 41 Args: |
| 42 temp_dir: The directory containing the PNG file. Must be the only file in |
| 43 the directory. |
| 44 png_filename: The full path to the PNG file to optimize. |
| 45 |
| 46 Returns: |
| 47 The raw bytes of a PNG file, an optimized version of the input. |
| 48 """ |
| 49 logging.debug('Crushing PNG image...') |
| 50 args = [OPTIMIZE_PNG_FILES] |
| 51 if optimization_level is not None: |
| 52 args.append('-o%d' % optimization_level) |
| 53 args.append(temp_dir) |
| 54 result = subprocess.call(args, stdout=sys.stderr) |
| 55 if result != 0: |
| 56 logging.warning('Warning: optimize-png-files failed (%d)', result) |
| 57 else: |
| 58 logging.debug('optimize-png-files succeeded') |
| 59 |
| 60 with open(png_filename, 'rb') as png_file: |
| 61 return png_file.read() |
| 62 |
| 63 def OptimizePng(png_data, optimization_level=None): |
| 64 """Optimize a PNG. |
| 65 |
| 66 Args: |
| 67 png_data: The raw bytes of a PNG file. |
| 68 |
| 69 Returns: |
| 70 The raw bytes of a PNG file, an optimized version of the input. |
| 71 """ |
| 72 temp_dir = tempfile.mkdtemp() |
| 73 try: |
| 74 logging.debug('temp_dir = %s', temp_dir) |
| 75 png_filename = os.path.join(temp_dir, 'image.png') |
| 76 with open(png_filename, 'wb') as png_file: |
| 77 png_file.write(png_data) |
| 78 return OptimizePngFile(temp_dir, png_filename, |
| 79 optimization_level=optimization_level) |
| 80 |
| 81 finally: |
| 82 if os.path.exists(png_filename): |
| 83 os.unlink(png_filename) |
| 84 os.rmdir(temp_dir) |
| 85 |
| 86 def OptimizeIcoFile(infile, outfile, optimization_level=None): |
| 87 """Read an ICO file, optimize its PNGs, and write the output to outfile. |
| 88 |
| 89 Args: |
| 90 infile: The file to read from. Must be a seekable file-like object |
| 91 containing a Microsoft ICO file. |
| 92 outfile: The file to write to. |
| 93 """ |
| 94 filename = os.path.basename(infile.name) |
| 95 icondir = infile.read(6) |
| 96 zero, image_type, num_images = struct.unpack('<HHH', icondir) |
| 97 if zero != 0: |
| 98 raise InvalidFile('First word must be 0.') |
| 99 if image_type not in (1, 2): |
| 100 raise InvalidFile('Image type must be 1 or 2.') |
| 101 |
| 102 # Read and unpack each ICONDIRENTRY. |
| 103 icon_dir_entries = [] |
| 104 for i in range(num_images): |
| 105 icondirentry = infile.read(16) |
| 106 icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) |
| 107 |
| 108 # Read each icon's bitmap data, crush PNGs, and update icon dir entries. |
| 109 current_offset = infile.tell() |
| 110 icon_bitmap_data = [] |
| 111 for i in range(num_images): |
| 112 width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] |
| 113 width = width or 256 |
| 114 height = height or 256 |
| 115 offset = current_offset |
| 116 icon_data = infile.read(size) |
| 117 if len(icon_data) != size: |
| 118 raise EOFError() |
| 119 |
| 120 entry_is_png = IsPng(icon_data) |
| 121 logging.info('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, |
| 122 height, size, 'PNG' if entry_is_png else 'BMP') |
| 123 |
| 124 if entry_is_png: |
| 125 icon_data = OptimizePng(icon_data, optimization_level=optimization_level) |
| 126 elif width >= 256 or height >= 256: |
| 127 # TODO(mgiuca): Automatically convert large BMP images to PNGs. |
| 128 logging.warning('Entry #%d is a large image in uncompressed BMP format. ' |
| 129 'Please manually convert to PNG format before running ' |
| 130 'this utility.', i + 1) |
| 131 |
| 132 new_size = len(icon_data) |
| 133 current_offset += new_size |
| 134 icon_dir_entries[i] = (width % 256, height % 256, num_colors, r1, r2, r3, |
| 135 new_size, offset) |
| 136 icon_bitmap_data.append(icon_data) |
| 137 |
| 138 # Write the data back to outfile. |
| 139 outfile.write(icondir) |
| 140 for icon_dir_entry in icon_dir_entries: |
| 141 outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) |
| 142 for icon_bitmap in icon_bitmap_data: |
| 143 outfile.write(icon_bitmap) |
| 144 |
| 145 def main(args=None): |
| 146 if args is None: |
| 147 args = sys.argv[1:] |
| 148 |
| 149 parser = argparse.ArgumentParser(description='Crush Windows ICO files.') |
| 150 parser.add_argument('files', metavar='ICO', type=argparse.FileType('r+b'), |
| 151 nargs='+', help='.ico files to be crushed') |
| 152 parser.add_argument('-o', dest='optimization_level', metavar='OPT', type=int, |
| 153 help='optimization level') |
| 154 parser.add_argument('-d', '--debug', dest='debug', action='store_true', |
| 155 help='enable debug logging') |
| 156 |
| 157 args = parser.parse_args() |
| 158 |
| 159 if args.debug: |
| 160 logging.getLogger().setLevel(logging.DEBUG) |
| 161 |
| 162 for file in args.files: |
| 163 buf = StringIO.StringIO() |
| 164 file.seek(0, os.SEEK_END) |
| 165 old_length = file.tell() |
| 166 file.seek(0, os.SEEK_SET) |
| 167 OptimizeIcoFile(file, buf, args.optimization_level) |
| 168 |
| 169 new_length = len(buf.getvalue()) |
| 170 if new_length >= old_length: |
| 171 logging.info('%s : Could not reduce file size.', file.name) |
| 172 else: |
| 173 file.truncate(new_length) |
| 174 file.seek(0) |
| 175 file.write(buf.getvalue()) |
| 176 |
| 177 saving = old_length - new_length |
| 178 saving_percent = float(saving) / old_length |
| 179 logging.info('%s : %d => %d (%d bytes : %d %%)', file.name, old_length, |
| 180 new_length, saving, int(saving_percent * 100)) |
| 181 |
| 182 if __name__ == '__main__': |
| 183 sys.exit(main()) |
| OLD | NEW |