| # Copyright 2015 The Chromium Authors. All rights reserved. | 
 | # Use of this source code is governed by a BSD-style license that can be | 
 | # found in the LICENSE file. | 
 |  | 
 | import logging | 
 | import math | 
 | import os | 
 | import struct | 
 | import subprocess | 
 | import sys | 
 | import tempfile | 
 |  | 
 | OPTIMIZE_PNG_FILES = 'tools/resources/optimize-png-files.sh' | 
 | IMAGEMAGICK_CONVERT = 'convert' | 
 |  | 
 | logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') | 
 |  | 
 | class InvalidFile(Exception): | 
 |   """Represents an invalid ICO file.""" | 
 |  | 
 | def IsPng(png_data): | 
 |   """Determines whether a sequence of bytes is a PNG.""" | 
 |   return png_data.startswith(b'\x89PNG\r\n\x1a\n') | 
 |  | 
 |  | 
 | def OptimizePngFile(temp_dir, png_filename, optimization_level=None): | 
 |   """Optimize a PNG file. | 
 |  | 
 |   Args: | 
 |     temp_dir: The directory containing the PNG file. Must be the only file in | 
 |       the directory. | 
 |     png_filename: The full path to the PNG file to optimize. | 
 |  | 
 |   Returns: | 
 |     The raw bytes of a PNG file, an optimized version of the input. | 
 |   """ | 
 |   logging.debug('Crushing PNG image...') | 
 |   args = [OPTIMIZE_PNG_FILES] | 
 |   if optimization_level is not None: | 
 |     args.append('-o%d' % optimization_level) | 
 |   args.append(temp_dir) | 
 |   result = subprocess.call(args, stdout=sys.stderr) | 
 |   if result != 0: | 
 |     logging.warning('Warning: optimize-png-files failed (%d)', result) | 
 |   else: | 
 |     logging.debug('optimize-png-files succeeded') | 
 |  | 
 |   with open(png_filename, 'rb') as png_file: | 
 |     return png_file.read() | 
 |  | 
 | def OptimizePng(png_data, optimization_level=None): | 
 |   """Optimize a PNG. | 
 |  | 
 |   Args: | 
 |     png_data: The raw bytes of a PNG file. | 
 |  | 
 |   Returns: | 
 |     The raw bytes of a PNG file, an optimized version of the input. | 
 |   """ | 
 |   temp_dir = tempfile.mkdtemp() | 
 |   try: | 
 |     logging.debug('temp_dir = %s', temp_dir) | 
 |     png_filename = os.path.join(temp_dir, 'image.png') | 
 |     with open(png_filename, 'wb') as png_file: | 
 |       png_file.write(png_data) | 
 |     return OptimizePngFile(temp_dir, png_filename, | 
 |                            optimization_level=optimization_level) | 
 |  | 
 |   finally: | 
 |     if os.path.exists(png_filename): | 
 |       os.unlink(png_filename) | 
 |     os.rmdir(temp_dir) | 
 |  | 
 | def BytesPerRowBMP(width, bpp): | 
 |   """Computes the number of bytes per row in a Windows BMP image.""" | 
 |   # width * bpp / 8, rounded up to the nearest multiple of 4. | 
 |   return int(math.ceil(width * bpp / 32.0)) * 4 | 
 |  | 
 | def ExportSingleEntry(icon_dir_entry, icon_data, outfile): | 
 |   """Export a single icon dir entry to its own ICO file. | 
 |  | 
 |   Args: | 
 |     icon_dir_entry: Struct containing the fields of an ICONDIRENTRY. | 
 |     icon_data: Raw pixel data of the icon. | 
 |     outfile: File object to write to. | 
 |   """ | 
 |   # Write the ICONDIR header. | 
 |   logging.debug('len(icon_data) = %d', len(icon_data)) | 
 |   outfile.write(struct.pack('<HHH', 0, 1, 1)) | 
 |  | 
 |   # Write the ICONDIRENTRY header. | 
 |   width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entry | 
 |   offset = 22; | 
 |   icon_dir_entry = width, height, num_colors, r1, r2, r3, size, offset | 
 |   outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) | 
 |  | 
 |   # Write the image data. | 
 |   outfile.write(icon_data) | 
 |  | 
 | def ConvertIcoToPng(ico_filename, png_filename): | 
 |   """Convert a single-entry ICO file to a PNG image. | 
 |  | 
 |   Requires that the user has `convert` (ImageMagick) installed. | 
 |  | 
 |   Raises: | 
 |     OSError: If ImageMagick was not found. | 
 |     subprocess.CalledProcessError: If convert failed. | 
 |   """ | 
 |   logging.debug('Converting BMP image to PNG...') | 
 |   args = [IMAGEMAGICK_CONVERT, ico_filename, png_filename] | 
 |   result = subprocess.check_call(args, stdout=sys.stderr) | 
 |   logging.info('Converted BMP image to PNG format') | 
 |  | 
 | def OptimizeBmp(icon_dir_entry, icon_data): | 
 |   """Convert a BMP file to PNG and optimize it. | 
 |  | 
 |   Args: | 
 |     icon_dir_entry: Struct containing the fields of an ICONDIRENTRY. | 
 |     icon_data: Raw pixel data of the icon. | 
 |  | 
 |   Returns: | 
 |     The raw bytes of a PNG file, an optimized version of the input. | 
 |   """ | 
 |   temp_dir = tempfile.mkdtemp() | 
 |   try: | 
 |     logging.debug('temp_dir = %s', temp_dir) | 
 |     ico_filename = os.path.join(temp_dir, 'image.ico') | 
 |     png_filename = os.path.join(temp_dir, 'image.png') | 
 |     with open(ico_filename, 'wb') as ico_file: | 
 |       logging.debug('writing %s', ico_filename) | 
 |       ExportSingleEntry(icon_dir_entry, icon_data, ico_file) | 
 |  | 
 |     try: | 
 |       ConvertIcoToPng(ico_filename, png_filename) | 
 |     except Exception as e: | 
 |       logging.warning('Could not convert BMP to PNG format: %s', e) | 
 |       if isinstance(e, OSError): | 
 |         logging.info('This is because ImageMagick (`convert`) was not found. ' | 
 |                      'Please install it, or manually convert large BMP images ' | 
 |                      'into PNG before running this utility.') | 
 |       return icon_data | 
 |  | 
 |     return OptimizePngFile(temp_dir, png_filename) | 
 |  | 
 |   finally: | 
 |     if os.path.exists(ico_filename): | 
 |       os.unlink(ico_filename) | 
 |     if os.path.exists(png_filename): | 
 |       os.unlink(png_filename) | 
 |     os.rmdir(temp_dir) | 
 |  | 
 | def ComputeANDMaskFromAlpha(image_data, width, height): | 
 |   """Compute an AND mask from 32-bit BGRA image data.""" | 
 |   and_bytes = [] | 
 |   for y in range(height): | 
 |     bit_count = 0 | 
 |     current_byte = 0 | 
 |     for x in range(width): | 
 |       alpha = image_data[(y * width + x) * 4 + 3] | 
 |       current_byte <<= 1 | 
 |       if alpha == 0: | 
 |         current_byte |= 1 | 
 |       bit_count += 1 | 
 |       if bit_count == 8: | 
 |         and_bytes.append(current_byte) | 
 |         bit_count = 0 | 
 |         current_byte = 0 | 
 |  | 
 |     # At the end of a row, pad the current byte. | 
 |     if bit_count > 0: | 
 |       current_byte <<= (8 - bit_count) | 
 |       and_bytes.append(current_byte) | 
 |     # And keep padding until a multiple of 4 bytes. | 
 |     while len(and_bytes) % 4 != 0: | 
 |       and_bytes.append(0) | 
 |  | 
 |   and_bytes = bytes(and_bytes) | 
 |   return and_bytes | 
 |  | 
 | def CheckANDMaskAgainstAlpha(xor_data, and_data, width, height): | 
 |   """Checks whether an AND mask is "good" for 32-bit BGRA image data. | 
 |  | 
 |   This checks that the mask is opaque wherever the alpha channel is not fully | 
 |   transparent. Pixels that violate this condition will show up as black in some | 
 |   contexts in Windows (http://crbug.com/526622). Also checks the inverse | 
 |   condition, that the mask is transparent wherever the alpha channel is fully | 
 |   transparent. While this does not appear to be strictly necessary, it is good | 
 |   practice for backwards compatibility. | 
 |  | 
 |   Returns True if the AND mask is "good", False otherwise. | 
 |   """ | 
 |   xor_bytes_per_row = width * 4 | 
 |   and_bytes_per_row = BytesPerRowBMP(width, 1) | 
 |  | 
 |   for y in range(height): | 
 |     for x in range(width): | 
 |       alpha = xor_data[y * xor_bytes_per_row + x * 4 + 3] | 
 |       mask = bool(and_data[y * and_bytes_per_row + x // 8] & (1 << (7 - | 
 |                                                                     (x % 8)))) | 
 |  | 
 |       if mask: | 
 |         if alpha > 0: | 
 |           # mask is transparent, alpha is partially or fully opaque. This pixel | 
 |           # can show up as black on Windows due to a rendering bug. | 
 |           return False | 
 |       else: | 
 |         if alpha == 0: | 
 |           # mask is opaque, alpha is transparent. This pixel should be marked as | 
 |           # transparent in the mask, for legacy reasons. | 
 |           return False | 
 |  | 
 |   return True | 
 |  | 
 | def CheckOrRebuildANDMask(iconimage, rebuild=False): | 
 |   """Checks the AND mask in an icon image for correctness, or rebuilds it. | 
 |  | 
 |   GIMP (<=2.8.14) creates a bad AND mask on 32-bit icon images (pixels with <50% | 
 |   opacity are marked as transparent, which end up looking black on Windows). | 
 |   With rebuild == False, checks whether the mask is bad. With rebuild == True, | 
 |   if this is a 32-bit image, throw the mask away and recompute it from the alpha | 
 |   data. (See: https://bugzilla.gnome.org/show_bug.cgi?id=755200) | 
 |  | 
 |   Args: | 
 |     iconimage: Bytes of an icon image (the BMP data for an entry in an ICO | 
 |       file). Must be in BMP format, not PNG. Does not need to be 32-bit (if it | 
 |       is not 32-bit, this is a no-op). | 
 |  | 
 |   Returns: | 
 |     If rebuild == False, a bool indicating whether the mask is "good". If | 
 |     rebuild == True, an updated |iconimage|, with the AND mask re-computed using | 
 |     ComputeANDMaskFromAlpha. | 
 |   """ | 
 |   # Parse BITMAPINFOHEADER. | 
 |   (_, width, height, _, bpp, _, _, _, _, num_colors, _) = struct.unpack( | 
 |       '<LLLHHLLLLLL', iconimage[:40]) | 
 |  | 
 |   if bpp != 32: | 
 |     # No alpha channel, so the mask cannot be "wrong" (it is the only source of | 
 |     # transparency information). | 
 |     return iconimage if rebuild else True | 
 |  | 
 |   height = height // 2 | 
 |   xor_size = BytesPerRowBMP(width, bpp) * height | 
 |  | 
 |   # num_colors can be 0, implying 2^bpp colors. | 
 |   xor_palette_size = (num_colors or (1 << bpp if bpp < 24 else 0)) * 4 | 
 |   xor_data = iconimage[40 + xor_palette_size : | 
 |                        40 + xor_palette_size + xor_size] | 
 |  | 
 |   if rebuild: | 
 |     and_data = ComputeANDMaskFromAlpha(xor_data, width, height) | 
 |  | 
 |     # Replace the AND mask in the original icon data. | 
 |     return iconimage[:40 + xor_palette_size + xor_size] + and_data | 
 |   else: | 
 |     and_data = iconimage[40 + xor_palette_size + xor_size:] | 
 |     return CheckANDMaskAgainstAlpha(xor_data, and_data, width, height) | 
 |  | 
 | def LintIcoFile(infile): | 
 |   """Read an ICO file and check whether it is acceptable. | 
 |  | 
 |   This checks for: | 
 |   - Basic structural integrity of the ICO. | 
 |   - Large BMPs that could be converted to PNGs. | 
 |   - 32-bit BMPs with buggy AND masks. | 
 |  | 
 |   It will *not* check whether PNG images have been compressed sufficiently. | 
 |  | 
 |   Args: | 
 |     infile: The file to read from. Must be a seekable file-like object | 
 |       containing a Microsoft ICO file. | 
 |  | 
 |   Returns: | 
 |     A sequence of strings, containing error messages. An empty sequence | 
 |     indicates a good icon. | 
 |   """ | 
 |   filename = os.path.basename(infile.name) | 
 |   icondir = infile.read(6) | 
 |   zero, image_type, num_images = struct.unpack('<HHH', icondir) | 
 |   if zero != 0: | 
 |     yield 'Invalid ICO: First word must be 0.' | 
 |     return | 
 |  | 
 |   if image_type not in (1, 2): | 
 |     yield 'Invalid ICO: Image type must be 1 or 2.' | 
 |     return | 
 |  | 
 |   # Read and unpack each ICONDIRENTRY. | 
 |   icon_dir_entries = [] | 
 |   for i in range(num_images): | 
 |     icondirentry = infile.read(16) | 
 |     icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) | 
 |  | 
 |   # Read each icon's bitmap data. | 
 |   current_offset = infile.tell() | 
 |   icon_bitmap_data = [] | 
 |   for i in range(num_images): | 
 |     width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] | 
 |     width = width or 256 | 
 |     height = height or 256 | 
 |     offset = current_offset | 
 |     icon_data = infile.read(size) | 
 |     if len(icon_data) != size: | 
 |       yield 'Invalid ICO: Unexpected end of file' | 
 |       return | 
 |  | 
 |     entry_is_png = IsPng(icon_data) | 
 |     logging.debug('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, | 
 |                   height, size, 'PNG' if entry_is_png else 'BMP') | 
 |  | 
 |     if not entry_is_png: | 
 |       if width >= 256 or height >= 256: | 
 |         yield ('Entry #%d is a large image in uncompressed BMP format. It ' | 
 |                'should be in PNG format.' % (i + 1)) | 
 |  | 
 |       if not CheckOrRebuildANDMask(icon_data, rebuild=False): | 
 |         yield ('Entry #%d has a bad mask that will display incorrectly in some ' | 
 |                'places in Windows.' % (i + 1)) | 
 |  | 
 | def OptimizeIcoFile(infile, outfile, optimization_level=None): | 
 |   """Read an ICO file, optimize its PNGs, and write the output to outfile. | 
 |  | 
 |   Args: | 
 |     infile: The file to read from. Must be a seekable file-like object | 
 |       containing a Microsoft ICO file. | 
 |     outfile: The file to write to. | 
 |   """ | 
 |   filename = os.path.basename(infile.name) | 
 |   icondir = infile.read(6) | 
 |   zero, image_type, num_images = struct.unpack('<HHH', icondir) | 
 |   if zero != 0: | 
 |     raise InvalidFile('First word must be 0.') | 
 |   if image_type not in (1, 2): | 
 |     raise InvalidFile('Image type must be 1 or 2.') | 
 |  | 
 |   # Read and unpack each ICONDIRENTRY. | 
 |   icon_dir_entries = [] | 
 |   for i in range(num_images): | 
 |     icondirentry = infile.read(16) | 
 |     icon_dir_entries.append(struct.unpack('<BBBBHHLL', icondirentry)) | 
 |  | 
 |   # Read each icon's bitmap data, crush PNGs, and update icon dir entries. | 
 |   current_offset = infile.tell() | 
 |   icon_bitmap_data = [] | 
 |   for i in range(num_images): | 
 |     width, height, num_colors, r1, r2, r3, size, _ = icon_dir_entries[i] | 
 |     width = width or 256 | 
 |     height = height or 256 | 
 |     offset = current_offset | 
 |     icon_data = infile.read(size) | 
 |     if len(icon_data) != size: | 
 |       raise EOFError() | 
 |  | 
 |     entry_is_png = IsPng(icon_data) | 
 |     logging.info('%s entry #%d: %dx%d, %d bytes (%s)', filename, i + 1, width, | 
 |                  height, size, 'PNG' if entry_is_png else 'BMP') | 
 |  | 
 |     if entry_is_png: | 
 |       # It is a PNG. Crush it. | 
 |       icon_data = OptimizePng(icon_data, optimization_level=optimization_level) | 
 |     elif width >= 256 or height >= 256: | 
 |       # It is a large BMP. Reformat as a PNG, then crush it. | 
 |       # Note: Smaller images are kept uncompressed, for compatibility with | 
 |       # Windows XP. | 
 |       # TODO(mgiuca): Now that we no longer support XP, we can probably compress | 
 |       # all of the images. https://crbug.com/663136 | 
 |       icon_data = OptimizeBmp(icon_dir_entries[i], icon_data) | 
 |     else: | 
 |       new_icon_data = CheckOrRebuildANDMask(icon_data, rebuild=True) | 
 |       if new_icon_data != icon_data: | 
 |         logging.info('  * Rebuilt AND mask for this image from alpha channel.') | 
 |         icon_data = new_icon_data | 
 |  | 
 |     new_size = len(icon_data) | 
 |     current_offset += new_size | 
 |     icon_dir_entries[i] = (width % 256, height % 256, num_colors, r1, r2, r3, | 
 |                            new_size, offset) | 
 |     icon_bitmap_data.append(icon_data) | 
 |  | 
 |   # Write the data back to outfile. | 
 |   outfile.write(icondir) | 
 |   for icon_dir_entry in icon_dir_entries: | 
 |     outfile.write(struct.pack('<BBBBHHLL', *icon_dir_entry)) | 
 |   for icon_bitmap in icon_bitmap_data: | 
 |     outfile.write(icon_bitmap) |