mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 1 | # Copyright 2015 The Chromium Authors. All rights reserved. |
| 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
| 4 | |
| 5 | import logging |
| 6 | import math |
| 7 | import os |
| 8 | import struct |
| 9 | import subprocess |
| 10 | import sys |
| 11 | import tempfile |
| 12 | |
| 13 | OPTIMIZE_PNG_FILES = 'tools/resources/optimize-png-files.sh' |
mgiuca | 0d45980 | 2016-11-08 00:35:05 | [diff] [blame] | 14 | IMAGEMAGICK_CONVERT = 'convert' |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 15 | |
| 16 | logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') |
| 17 | |
| 18 | class InvalidFile(Exception): |
| 19 | """Represents an invalid ICO file.""" |
| 20 | |
| 21 | def IsPng(png_data): |
| 22 | """Determines whether a sequence of bytes is a PNG.""" |
| 23 | return png_data.startswith('\x89PNG\r\n\x1a\n') |
| 24 | |
| 25 | def OptimizePngFile(temp_dir, png_filename, optimization_level=None): |
| 26 | """Optimize a PNG file. |
| 27 | |
| 28 | Args: |
| 29 | temp_dir: The directory containing the PNG file. Must be the only file in |
| 30 | the directory. |
| 31 | png_filename: The full path to the PNG file to optimize. |
| 32 | |
| 33 | Returns: |
| 34 | The raw bytes of a PNG file, an optimized version of the input. |
| 35 | """ |
| 36 | logging.debug('Crushing PNG image...') |
| 37 | args = [OPTIMIZE_PNG_FILES] |
| 38 | if optimization_level is not None: |
| 39 | args.append('-o%d' % optimization_level) |
| 40 | args.append(temp_dir) |
| 41 | result = subprocess.call(args, stdout=sys.stderr) |
| 42 | if result != 0: |
| 43 | logging.warning('Warning: optimize-png-files failed (%d)', result) |
| 44 | else: |
| 45 | logging.debug('optimize-png-files succeeded') |
| 46 | |
| 47 | with open(png_filename, 'rb') as png_file: |
| 48 | return png_file.read() |
| 49 | |
| 50 | def OptimizePng(png_data, optimization_level=None): |
| 51 | """Optimize a PNG. |
| 52 | |
| 53 | Args: |
| 54 | png_data: The raw bytes of a PNG file. |
| 55 | |
| 56 | Returns: |
| 57 | The raw bytes of a PNG file, an optimized version of the input. |
| 58 | """ |
| 59 | temp_dir = tempfile.mkdtemp() |
| 60 | try: |
| 61 | logging.debug('temp_dir = %s', temp_dir) |
| 62 | png_filename = os.path.join(temp_dir, 'image.png') |
| 63 | with open(png_filename, 'wb') as png_file: |
| 64 | png_file.write(png_data) |
| 65 | return OptimizePngFile(temp_dir, png_filename, |
| 66 | optimization_level=optimization_level) |
| 67 | |
| 68 | finally: |
| 69 | if os.path.exists(png_filename): |
| 70 | os.unlink(png_filename) |
| 71 | os.rmdir(temp_dir) |
| 72 | |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 73 | def BytesPerRowBMP(width, bpp): |
| 74 | """Computes the number of bytes per row in a Windows BMP image.""" |
| 75 | # width * bpp / 8, rounded up to the nearest multiple of 4. |
| 76 | return int(math.ceil(width * bpp / 32.0)) * 4 |
| 77 | |
mgiuca | 0d45980 | 2016-11-08 00:35:05 | [diff] [blame] | 78 | def ExportSingleEntry(icon_dir_entry, icon_data, outfile): |
| 79 | """Export a single icon dir entry to its own ICO file. |
| 80 | |
| 81 | Args: |
| 82 | icon_dir_entry: Struct containing the fields of an ICONDIRENTRY. |
| 83 | icon_data: Raw pixel data of the icon. |
| 84 | outfile: File object to write to. |
| 85 | """ |
| 86 | # Write the ICONDIR header. |
| 87 | logging.debug('len(icon_data) = %d', len(icon_data)) |
| 88 | outfile.write(struct.pack('<HHH', 0, 1, 1)) |
| 89 | |
| 90 | # Write the ICONDIRENTRY header. |
| 91 | width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entry |
| 92 | offset = 22; |
| 93 | icon_dir_entry = width, height, num_colors, r1, r2, r3, size, offset |
| 94 | outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) |
| 95 | |
| 96 | # Write the image data. |
| 97 | outfile.write(icon_data) |
| 98 | |
| 99 | def ConvertIcoToPng(ico_filename, png_filename): |
| 100 | """Convert a single-entry ICO file to a PNG image. |
| 101 | |
| 102 | Requires that the user has `convert` (ImageMagick) installed. |
| 103 | |
| 104 | Raises: |
| 105 | OSError: If ImageMagick was not found. |
| 106 | subprocess.CalledProcessError: If convert failed. |
| 107 | """ |
| 108 | logging.debug('Converting BMP image to PNG...') |
| 109 | args = [IMAGEMAGICK_CONVERT, ico_filename, png_filename] |
| 110 | result = subprocess.check_call(args, stdout=sys.stderr) |
| 111 | logging.info('Converted BMP image to PNG format') |
| 112 | |
| 113 | def OptimizeBmp(icon_dir_entry, icon_data): |
| 114 | """Convert a BMP file to PNG and optimize it. |
| 115 | |
| 116 | Args: |
| 117 | icon_dir_entry: Struct containing the fields of an ICONDIRENTRY. |
| 118 | icon_data: Raw pixel data of the icon. |
| 119 | |
| 120 | Returns: |
| 121 | The raw bytes of a PNG file, an optimized version of the input. |
| 122 | """ |
| 123 | temp_dir = tempfile.mkdtemp() |
| 124 | try: |
| 125 | logging.debug('temp_dir = %s', temp_dir) |
| 126 | ico_filename = os.path.join(temp_dir, 'image.ico') |
| 127 | png_filename = os.path.join(temp_dir, 'image.png') |
| 128 | with open(ico_filename, 'wb') as ico_file: |
| 129 | logging.debug('writing %s', ico_filename) |
| 130 | ExportSingleEntry(icon_dir_entry, icon_data, ico_file) |
| 131 | |
| 132 | try: |
| 133 | ConvertIcoToPng(ico_filename, png_filename) |
| 134 | except Exception as e: |
| 135 | logging.warning('Could not convert BMP to PNG format: %s', e) |
| 136 | if isinstance(e, OSError): |
| 137 | logging.info('This is because ImageMagick (`convert`) was not found. ' |
| 138 | 'Please install it, or manually convert large BMP images ' |
| 139 | 'into PNG before running this utility.') |
| 140 | return icon_data |
| 141 | |
| 142 | return OptimizePngFile(temp_dir, png_filename) |
| 143 | |
| 144 | finally: |
| 145 | if os.path.exists(ico_filename): |
| 146 | os.unlink(ico_filename) |
| 147 | if os.path.exists(png_filename): |
| 148 | os.unlink(png_filename) |
| 149 | os.rmdir(temp_dir) |
| 150 | |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 151 | def ComputeANDMaskFromAlpha(image_data, width, height): |
| 152 | """Compute an AND mask from 32-bit BGRA image data.""" |
| 153 | and_bytes = [] |
| 154 | for y in range(height): |
| 155 | bit_count = 0 |
| 156 | current_byte = 0 |
| 157 | for x in range(width): |
| 158 | alpha = image_data[(y * width + x) * 4 + 3] |
| 159 | current_byte <<= 1 |
| 160 | if ord(alpha) == 0: |
| 161 | current_byte |= 1 |
| 162 | bit_count += 1 |
| 163 | if bit_count == 8: |
| 164 | and_bytes.append(current_byte) |
| 165 | bit_count = 0 |
| 166 | current_byte = 0 |
| 167 | |
| 168 | # At the end of a row, pad the current byte. |
| 169 | if bit_count > 0: |
| 170 | current_byte <<= (8 - bit_count) |
| 171 | and_bytes.append(current_byte) |
| 172 | # And keep padding until a multiple of 4 bytes. |
| 173 | while len(and_bytes) % 4 != 0: |
| 174 | and_bytes.append(0) |
| 175 | |
| 176 | and_bytes = ''.join(map(chr, and_bytes)) |
| 177 | return and_bytes |
| 178 | |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 179 | def CheckANDMaskAgainstAlpha(xor_data, and_data, width, height): |
| 180 | """Checks whether an AND mask is "good" for 32-bit BGRA image data. |
| 181 | |
| 182 | This checks that the mask is opaque wherever the alpha channel is not fully |
| 183 | transparent. Pixels that violate this condition will show up as black in some |
| 184 | contexts in Windows (http://crbug.com/526622). Also checks the inverse |
| 185 | condition, that the mask is transparent wherever the alpha channel is fully |
| 186 | transparent. While this does not appear to be strictly necessary, it is good |
| 187 | practice for backwards compatibility. |
| 188 | |
| 189 | Returns True if the AND mask is "good", False otherwise. |
| 190 | """ |
| 191 | xor_bytes_per_row = width * 4 |
| 192 | and_bytes_per_row = BytesPerRowBMP(width, 1) |
| 193 | |
| 194 | for y in range(height): |
| 195 | for x in range(width): |
| 196 | alpha = ord(xor_data[y * xor_bytes_per_row + x * 4 + 3]) |
| 197 | mask = bool(ord(and_data[y * and_bytes_per_row + x // 8]) & |
| 198 | (1 << (7 - (x % 8)))) |
| 199 | |
| 200 | if mask: |
| 201 | if alpha > 0: |
| 202 | # mask is transparent, alpha is partially or fully opaque. This pixel |
| 203 | # can show up as black on Windows due to a rendering bug. |
| 204 | return False |
| 205 | else: |
| 206 | if alpha == 0: |
| 207 | # mask is opaque, alpha is transparent. This pixel should be marked as |
| 208 | # transparent in the mask, for legacy reasons. |
| 209 | return False |
| 210 | |
| 211 | return True |
| 212 | |
| 213 | def CheckOrRebuildANDMask(iconimage, rebuild=False): |
| 214 | """Checks the AND mask in an icon image for correctness, or rebuilds it. |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 215 | |
| 216 | GIMP (<=2.8.14) creates a bad AND mask on 32-bit icon images (pixels with <50% |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 217 | opacity are marked as transparent, which end up looking black on Windows). |
| 218 | With rebuild == False, checks whether the mask is bad. With rebuild == True, |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 219 | if this is a 32-bit image, throw the mask away and recompute it from the alpha |
| 220 | data. (See: https://bugzilla.gnome.org/show_bug.cgi?id=755200) |
| 221 | |
| 222 | Args: |
| 223 | iconimage: Bytes of an icon image (the BMP data for an entry in an ICO |
| 224 | file). Must be in BMP format, not PNG. Does not need to be 32-bit (if it |
| 225 | is not 32-bit, this is a no-op). |
| 226 | |
| 227 | Returns: |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 228 | If rebuild == False, a bool indicating whether the mask is "good". If |
| 229 | rebuild == True, an updated |iconimage|, with the AND mask re-computed using |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 230 | ComputeANDMaskFromAlpha. |
| 231 | """ |
| 232 | # Parse BITMAPINFOHEADER. |
| 233 | (_, width, height, _, bpp, _, _, _, _, num_colors, _) = struct.unpack( |
| 234 | '<LLLHHLLLLLL', iconimage[:40]) |
| 235 | |
| 236 | if bpp != 32: |
| 237 | # No alpha channel, so the mask cannot be "wrong" (it is the only source of |
| 238 | # transparency information). |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 239 | return iconimage if rebuild else True |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 240 | |
| 241 | height /= 2 |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 242 | xor_size = BytesPerRowBMP(width, bpp) * height |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 243 | |
| 244 | # num_colors can be 0, implying 2^bpp colors. |
| 245 | xor_palette_size = (num_colors or (1 << bpp if bpp < 24 else 0)) * 4 |
| 246 | xor_data = iconimage[40 + xor_palette_size : |
| 247 | 40 + xor_palette_size + xor_size] |
| 248 | |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 249 | if rebuild: |
| 250 | and_data = ComputeANDMaskFromAlpha(xor_data, width, height) |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 251 | |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 252 | # Replace the AND mask in the original icon data. |
| 253 | return iconimage[:40 + xor_palette_size + xor_size] + and_data |
| 254 | else: |
| 255 | and_data = iconimage[40 + xor_palette_size + xor_size:] |
| 256 | return CheckANDMaskAgainstAlpha(xor_data, and_data, width, height) |
| 257 | |
| 258 | def LintIcoFile(infile): |
| 259 | """Read an ICO file and check whether it is acceptable. |
| 260 | |
| 261 | This checks for: |
| 262 | - Basic structural integrity of the ICO. |
| 263 | - Large BMPs that could be converted to PNGs. |
| 264 | - 32-bit BMPs with buggy AND masks. |
| 265 | |
| 266 | It will *not* check whether PNG images have been compressed sufficiently. |
| 267 | |
| 268 | Args: |
| 269 | infile: The file to read from. Must be a seekable file-like object |
| 270 | containing a Microsoft ICO file. |
| 271 | |
| 272 | Returns: |
| 273 | A sequence of strings, containing error messages. An empty sequence |
| 274 | indicates a good icon. |
| 275 | """ |
| 276 | filename = os.path.basename(infile.name) |
| 277 | icondir = infile.read(6) |
| 278 | zero, image_type, num_images = struct.unpack('<HHH', icondir) |
| 279 | if zero != 0: |
| 280 | yield 'Invalid ICO: First word must be 0.' |
| 281 | return |
| 282 | |
| 283 | if image_type not in (1, 2): |
| 284 | yield 'Invalid ICO: Image type must be 1 or 2.' |
| 285 | return |
| 286 | |
| 287 | # Read and unpack each ICONDIRENTRY. |
| 288 | icon_dir_entries = [] |
| 289 | for i in range(num_images): |
| 290 | icondirentry = infile.read(16) |
| 291 | icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) |
| 292 | |
| 293 | # Read each icon's bitmap data. |
| 294 | current_offset = infile.tell() |
| 295 | icon_bitmap_data = [] |
| 296 | for i in range(num_images): |
| 297 | width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] |
| 298 | width = width or 256 |
| 299 | height = height or 256 |
| 300 | offset = current_offset |
| 301 | icon_data = infile.read(size) |
| 302 | if len(icon_data) != size: |
| 303 | yield 'Invalid ICO: Unexpected end of file' |
| 304 | return |
| 305 | |
| 306 | entry_is_png = IsPng(icon_data) |
| 307 | logging.debug('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, |
| 308 | height, size, 'PNG' if entry_is_png else 'BMP') |
| 309 | |
| 310 | if not entry_is_png: |
| 311 | if width >= 256 or height >= 256: |
| 312 | yield ('Entry #%d is a large image in uncompressed BMP format. It ' |
| 313 | 'should be in PNG format.' % (i + 1)) |
| 314 | |
| 315 | if not CheckOrRebuildANDMask(icon_data, rebuild=False): |
| 316 | yield ('Entry #%d has a bad mask that will display incorrectly in some ' |
| 317 | 'places in Windows.' % (i + 1)) |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 318 | |
| 319 | def OptimizeIcoFile(infile, outfile, optimization_level=None): |
| 320 | """Read an ICO file, optimize its PNGs, and write the output to outfile. |
| 321 | |
| 322 | Args: |
| 323 | infile: The file to read from. Must be a seekable file-like object |
| 324 | containing a Microsoft ICO file. |
| 325 | outfile: The file to write to. |
| 326 | """ |
| 327 | filename = os.path.basename(infile.name) |
| 328 | icondir = infile.read(6) |
| 329 | zero, image_type, num_images = struct.unpack('<HHH', icondir) |
| 330 | if zero != 0: |
| 331 | raise InvalidFile('First word must be 0.') |
| 332 | if image_type not in (1, 2): |
| 333 | raise InvalidFile('Image type must be 1 or 2.') |
| 334 | |
| 335 | # Read and unpack each ICONDIRENTRY. |
| 336 | icon_dir_entries = [] |
| 337 | for i in range(num_images): |
| 338 | icondirentry = infile.read(16) |
| 339 | icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) |
| 340 | |
| 341 | # Read each icon's bitmap data, crush PNGs, and update icon dir entries. |
| 342 | current_offset = infile.tell() |
| 343 | icon_bitmap_data = [] |
| 344 | for i in range(num_images): |
| 345 | width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] |
| 346 | width = width or 256 |
| 347 | height = height or 256 |
| 348 | offset = current_offset |
| 349 | icon_data = infile.read(size) |
| 350 | if len(icon_data) != size: |
| 351 | raise EOFError() |
| 352 | |
| 353 | entry_is_png = IsPng(icon_data) |
| 354 | logging.info('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, |
| 355 | height, size, 'PNG' if entry_is_png else 'BMP') |
| 356 | |
| 357 | if entry_is_png: |
mgiuca | 0d45980 | 2016-11-08 00:35:05 | [diff] [blame] | 358 | # It is a PNG. Crush it. |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 359 | icon_data = OptimizePng(icon_data, optimization_level=optimization_level) |
mgiuca | 0d45980 | 2016-11-08 00:35:05 | [diff] [blame] | 360 | elif width >= 256 or height >= 256: |
| 361 | # It is a large BMP. Reformat as a PNG, then crush it. |
| 362 | # Note: Smaller images are kept uncompressed, for compatibility with |
| 363 | # Windows XP. |
| 364 | # TODO(mgiuca): Now that we no longer support XP, we can probably compress |
| 365 | # all of the images. https://crbug.com/663136 |
| 366 | icon_data = OptimizeBmp(icon_dir_entries[i], icon_data) |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 367 | else: |
mgiuca | e56b0cf9 | 2016-11-09 00:03:48 | [diff] [blame] | 368 | new_icon_data = CheckOrRebuildANDMask(icon_data, rebuild=True) |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 369 | if new_icon_data != icon_data: |
| 370 | logging.info(' * Rebuilt AND mask for this image from alpha channel.') |
| 371 | icon_data = new_icon_data |
| 372 | |
mgiuca | 0e97ba4 | 2015-10-12 23:18:29 | [diff] [blame] | 373 | new_size = len(icon_data) |
| 374 | current_offset += new_size |
| 375 | icon_dir_entries[i] = (width % 256, height % 256, num_colors, r1, r2, r3, |
| 376 | new_size, offset) |
| 377 | icon_bitmap_data.append(icon_data) |
| 378 | |
| 379 | # Write the data back to outfile. |
| 380 | outfile.write(icondir) |
| 381 | for icon_dir_entry in icon_dir_entries: |
| 382 | outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) |
| 383 | for icon_bitmap in icon_bitmap_data: |
| 384 | outfile.write(icon_bitmap) |