blob: db0cda11853c200926737890d2921f6b9ac627ee [file] [log] [blame]
mgiuca0e97ba42015-10-12 23:18:291# 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
5import logging
6import math
7import os
8import struct
9import subprocess
10import sys
11import tempfile
12
13OPTIMIZE_PNG_FILES = 'tools/resources/optimize-png-files.sh'
mgiuca0d459802016-11-08 00:35:0514IMAGEMAGICK_CONVERT = 'convert'
mgiuca0e97ba42015-10-12 23:18:2915
16logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
17
18class InvalidFile(Exception):
19 """Represents an invalid ICO file."""
20
21def IsPng(png_data):
22 """Determines whether a sequence of bytes is a PNG."""
23 return png_data.startswith('\x89PNG\r\n\x1a\n')
24
25def 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
50def 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
mgiucae56b0cf92016-11-09 00:03:4873def 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
mgiuca0d459802016-11-08 00:35:0578def 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
99def 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
113def 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
mgiuca0e97ba42015-10-12 23:18:29151def 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
mgiucae56b0cf92016-11-09 00:03:48179def 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
213def CheckOrRebuildANDMask(iconimage, rebuild=False):
214 """Checks the AND mask in an icon image for correctness, or rebuilds it.
mgiuca0e97ba42015-10-12 23:18:29215
216 GIMP (<=2.8.14) creates a bad AND mask on 32-bit icon images (pixels with <50%
mgiucae56b0cf92016-11-09 00:03:48217 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,
mgiuca0e97ba42015-10-12 23:18:29219 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:
mgiucae56b0cf92016-11-09 00:03:48228 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
mgiuca0e97ba42015-10-12 23:18:29230 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).
mgiucae56b0cf92016-11-09 00:03:48239 return iconimage if rebuild else True
mgiuca0e97ba42015-10-12 23:18:29240
241 height /= 2
mgiucae56b0cf92016-11-09 00:03:48242 xor_size = BytesPerRowBMP(width, bpp) * height
mgiuca0e97ba42015-10-12 23:18:29243
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
mgiucae56b0cf92016-11-09 00:03:48249 if rebuild:
250 and_data = ComputeANDMaskFromAlpha(xor_data, width, height)
mgiuca0e97ba42015-10-12 23:18:29251
mgiucae56b0cf92016-11-09 00:03:48252 # 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
258def 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))
mgiuca0e97ba42015-10-12 23:18:29318
319def 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:
mgiuca0d459802016-11-08 00:35:05358 # It is a PNG. Crush it.
mgiuca0e97ba42015-10-12 23:18:29359 icon_data = OptimizePng(icon_data, optimization_level=optimization_level)
mgiuca0d459802016-11-08 00:35:05360 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)
mgiuca0e97ba42015-10-12 23:18:29367 else:
mgiucae56b0cf92016-11-09 00:03:48368 new_icon_data = CheckOrRebuildANDMask(icon_data, rebuild=True)
mgiuca0e97ba42015-10-12 23:18:29369 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
mgiuca0e97ba42015-10-12 23:18:29373 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)