| # 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('\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 ord(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 = ''.join(map(chr, 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 = ord(xor_data[y * xor_bytes_per_row + x * 4 + 3]) |
| mask = bool(ord(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 /= 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) |