/* * This shared module defines a single ImageUtils object in the global scope. * * ImageUtils.getSizeAndType() takes a Blob as its argument and * returns a Promise. If the promise resolves, the resolve method is * passed an object with width, height, and type properties. Width and * height specify the width and height of the image in pixels. Type * specifies the MIME type of the image. If the promise is rejected, * the reject method will be passed a string error message. * * Supported image types are JPEG, PNG, GIF and BMP. The MIME type * strings returned when a promise resolves are "image/jpeg", * "image/png", "image/gif", and "image/bmp". * * If you pass a blob that holds an image of some other type (or a * blob that is not an image at all) then promise will be rejected. * * getSizeAndType() does not decode the image to determine its size * because can require large amounts of memory. Instead, it parses the * image file to determine its size and typically only needs to read a * prefix of the file into memory. * * getSizeAndType() differs from the getImageSize() function defined * in shared/js/media/immage_size.js. That function is similar but * also parses EXIF metadata for JPEG images and is only suitable if * you need to access EXIF data. */ (function(exports) { 'use strict'; /* global Promise */ var ImageUtils = exports.ImageUtils = {}; // Define constants for the MIME types we use const JPEG = 'image/jpeg'; const PNG = 'image/png'; const GIF = 'image/gif'; const BMP = 'image/bmp'; // Export them ImageUtils.JPEG = JPEG; ImageUtils.PNG = PNG; ImageUtils.GIF = GIF; ImageUtils.BMP = BMP; ImageUtils.getSizeAndType = function getSizeAndType(imageBlob) { if (!(imageBlob instanceof Blob)) { console.log('not blob'); return Promise.reject(new TypeError('argument is not a Blob')); } return new Promise(function(resolve, reject) { if (imageBlob.size <= 16) { reject('corrupt image file'); return; } // Start off assuming that we'll read the first 32kb of the file var bytesToRead = 32 * 1024; // But if the blob indicates that this is a PNG, GIF, or BMP, then // we can read less since the size information is always close to the // start of the file for those formats. if (imageBlob.type === PNG || imageBlob.type === GIF || imageBlob.type === BMP) { bytesToRead = 512; } // Try to find the information we want in the first bytes of the file findSizeAndType(imageBlob, bytesToRead, success, tryagain); // If we got the size and type data, resolve the promise function success(data) { resolve(data); } // If we didn't find it, try again for JPEG files function tryagain(data) { // If we were able to verify that this blob is a jpeg file // then we will try again because in JPEG files, the size // comes after the EXIF data so if there is a large preview image, // the size might be more than 32kb into the file if (data.type === JPEG) { findSizeAndType(imageBlob, imageBlob.size, success, failure); } // For all other known image types the type and size data is // near the start of the file, if we didn't find it on this first // try we need to give up now. else { reject(data.error); } } // If we didn't find the data even after a second attempt then reject. function failure(data) { reject(data.error); } }); // This is the internal helper function that actually reads the blob // and finds type and size information. function findSizeAndType(imageBlob, amountToRead, success, failure) { var slice = imageBlob.slice(0, Math.min(amountToRead, imageBlob.size)); var reader = new FileReader(); reader.readAsArrayBuffer(slice); reader.onloadend = function() { parseImageData(reader.result); }; function parseImageData(buffer) { var header = new Uint8Array(buffer, 0, 16); var view = new DataView(buffer); if (header[0] === 0x89 && header[1] === 0x50 && /* P */ header[2] === 0x4e && /* N */ header[3] === 0x47 && /* G */ header[4] === 0x0d && /* \r */ header[5] === 0x0a && /* \n */ header[6] === 0x1A && header[7] === 0x0a && header[12] === 0x49 && /* I */ header[13] === 0x48 && /* H */ header[14] === 0x44 && /* D */ header[15] === 0x52) /* R */ { // This is a PNG file try { success({ type: PNG, width: view.getUint32(16, false), // 32 bit big endian width height: view.getUint32(20, false) // 32 bit big endian height }); } catch (ex) { failure({ error: ex.toString()}); } } else if (header[0] === 0x47 && /* G */ header[1] === 0x49 && /* I */ header[2] === 0x46 && /* F */ header[3] === 0x38 && /* 8 */ (header[4] === 0x37 || /* 7 */ header[4] === 0x39) && /* or 9 */ header[5] === 0x61) /* a */ { // This is a GIF file try { success({ type: GIF, width: view.getUint16(6, true), // 16-bit little endian width height: view.getUint16(8, true) // 16-big little endian height }); } catch (ex) { failure({ error: ex.toString() }); } } else if (header[0] === 0x42 && /* B */ header[1] === 0x4D && /* M */ view.getUint32(2, true) === imageBlob.size) { // This is a BMP file try { var width, height; if (view.getUint16(14, true) === 12) { // check format version width = view.getUint16(18, true); // 16-bit little endian width height = view.getUint16(20, true); // 16-bit little endian height } else { // newer versions of the format use 32-bit ints width = view.getUint32(18, true); // 32-bit little endian width height = view.getUint32(22, true); // 32-bit little endian height } success({ type: BMP, width: width, height: height }); } catch (ex) { failure({ error: ex.toString() }); } } else if (header[0] === 0xFF && header[1] === 0xD8) { var value = { type: JPEG }; // This is a JPEG file. To find its width and height we need // to skip past the EXIF data and find the start of image data. try { var offset = 2; // Loop through the segments of the file until we find an SOF // segment with image size or until we reach the end of our data. for (;;) { // The byte at the current offset should be 0xFF marking // the start of a new segment. if (view.getUint8(offset) !== 0xFF) { failure({ error: 'corrupt JPEG file' }); } var segmentType = view.getUint8(offset + 1); var segmentSize = view.getUint16(offset + 2) + 2; // If we found an SOF "Start of Frame" segment, we can get // the image size from it. if ((segmentType >= 0xC0 && segmentType <= 0xC3) || (segmentType >= 0xC5 && segmentType <= 0xC7) || (segmentType >= 0xC9 && segmentType <= 0xCB) || (segmentType >= 0xCD && segmentType <= 0xCF)) { // 16-bit big-endian dimensions, height first value.height = view.getUint16(offset + 5, false); value.width = view.getUint16(offset + 7, false); success(value); break; } // Otherwise, move on to the next segment offset += segmentSize; // We may not have read the entire file into our array buffer // so we have to make sure we have not gone past the end of // the buffer before looping again. Note that in this case // the object we pass to the failure callback includes the // image type. This is a signal to getSizeAndType that it // should try again with a larger ArrayBuffer. if (offset + 9 > view.byteLength) { value.error = 'corrupt JPEG file'; failure(value); break; } } } catch (ex) { failure({ error: ex.toString() }); } } else { failure({ error: 'unknown image type' }); } } } }; // // Given a blob containing an image file, this function returns a // Promise that will resolve to a blob for an image file that has // the specified width and height. If the blob already has that // size, it is returned unchanged. If the image is bigger than the // specified size, then it will be shrunk and cropped as necessary // to fit the specified dimensions. If the image is smaller than // the specified size, it will be enlarged and cropped to fit. The // cropping is done in the same way as with the CSS // background-size:cover attribute. // // If the image is resized and outputType is "image/jpeg" or // "image/png" then that type will be used for the resized image. If // a JPEG or PNG image is resized and outputType is not specified, // then the output image will have the same type as the input image. // In all other cases, a resized image will be encoded as a PNG image. // Note that when an image does not need to be resized its type is not // changed, even if outputType is specified. // 'encoderOptions' is passed 'as is' to the 'toBlob' method of canvas: // A Number between 0 and 1 indicating image quality if the requested type // is image/jpeg or image/webp. If this argument is anything else, // the default value for image quality is used. ImageUtils.resizeAndCropToCover = function(inputImageBlob, outputWidth, outputHeight, outputType, encoderOptions) { if (!outputWidth || !isFinite(outputWidth) || outputWidth <= 0 || !outputHeight || !isFinite(outputHeight) || outputHeight <= 0) { return Promise.reject(new TypeError('invalid output dimensions')); } outputWidth = Math.round(outputWidth); outputHeight = Math.round(outputHeight); return ImageUtils.getSizeAndType(inputImageBlob).then( function resolve(data) { var inputWidth = data.width; var inputHeight = data.height; // If the image already has the desired size, just return it unchanged if (inputWidth === outputWidth && inputHeight === outputHeight) { return inputImageBlob; } // Otherwise, return a promise for the resized image return resize(data); }, function reject(error) { // If we couldn't determine the image size and type for some // reason we are still going to assume that it is a valid image, // just one we don't know about. If gecko can decode it we'll // use it and won't reject unless gecko refuses it. return resize({}); } ); // Decode the image in an element, resize and/or crop it into a // and the re-encode it to a blob again. Returns a Promise // that resolves to the encoded blob. function resize(data) { var inputType = data.type; var inputWidth = data.width; var inputHeight = data.height; // Ignore invalid outputType parameters if (outputType && outputType !== JPEG && outputType !== PNG) { console.warn('Ignoring unsupported outputType', outputType); outputType = undefined; } // Determine the output type if none was specified. if (!outputType) { if (inputType === JPEG || inputType === PNG) { outputType = inputType; } else { outputType = PNG; } } // Get a URL for the image blob so we can ask gecko to decode it. var url = URL.createObjectURL(inputImageBlob); // If we can downsample the image while decoding it, then add a // #-moz-samplesize media fragment to the URL. For large JPEG inputs // this can save megabytes of memory. var mediaFragment; if (inputType === JPEG && inputWidth > outputWidth && inputHeight > outputHeight) { // How much would we like to scale the image by while decoding? var reduction = Math.max(outputWidth / inputWidth, outputHeight / inputHeight); // Get a media fragment that will downsample the image by up to // this amount. mediaFragment = ImageUtils.Downsample.sizeNoMoreThan(reduction); } else { mediaFragment = ''; } // The next step is to decode (while possibly downsampling) the // image, but that is asynchronous, so we'll have to return a // Promise to handle that return new Promise(function(resolve, reject) { var offscreenImage = new Image(); offscreenImage.src = url + mediaFragment; offscreenImage.onerror = function(e) { cleanupImage(); reject('failed to decode image'); }; offscreenImage.onload = function() { // The image has been decoded now, so we know its actual size. // If getSizeAndType failed, or if we used a media fragment then // this may be different than the inputWidth and inputHeight // values we used previously. var actualWidth = offscreenImage.width; var actualHeight = offscreenImage.height; // Now figure how much we have to scale the decoded image down // (or up) so that it covers the specified output dimensions. var widthScale = outputWidth / actualWidth; var heightScale = outputHeight / actualHeight; var scale = Math.max(widthScale, heightScale); // Scaling the output dimensions by this much tells us the // dimensions of the crop area on the decoded image var cropWidth = Math.round(outputWidth / scale); var cropHeight = Math.round(outputHeight / scale); // Now center that crop region within the decoded image var cropLeft = Math.floor((actualWidth - cropWidth) / 2); var cropTop = Math.floor((actualHeight - cropHeight) / 2); // Set up the canvas we need to copy the crop region into var canvas = document.createElement('canvas'); canvas.width = outputWidth; canvas.height = outputHeight; var context = canvas.getContext('2d', { willReadFrequently: true }); // Copy the image into the canvas context.drawImage(offscreenImage, cropLeft, cropTop, cropWidth, cropHeight, 0, 0, outputWidth, outputHeight); // We're done with the image now: try to release the decoded // image memory as fast as we can cleanupImage(); // Now encode the pixels in the canvas as an image blob canvas.toBlob(function(blob) { // We've got the encoded image in the blob, so we don't need // the pixels in the canvas anymore. Try to release that memory // right away, too. canvas.width = 0; resolve(blob); }, outputType, encoderOptions); }; function cleanupImage() { offscreenImage.onerror = offscreenImage.onload = ''; offscreenImage.onload = offscreenImage.onerror = null; offscreenImage.removeAttribute('src'); URL.revokeObjectURL(url); } }); } }; // // This module defines a Downsample object with static // methods that return objects representing media fragments for // downsampling images while they are decoded. The current implementation // is based on the #-moz-samplesize media fragment. But because of // problems with that fragment (see bug 1004908) it seems likely that a // new syntax or new fragment will be introduced. If that happens, we can // just change this module and not have to change anything else that // depends on it. // // The method Downsample.areaAtLeast(scale) returns an object // representing a media fragment to use to decode an image downsampled by // at least as much as the specified scale. If you are trying to preview // an 8mp image and don't want to use more than 2mp of image memory, for // example, you would pass a scale of .25 (2mp/8mp) here, and the // resulting media fragment could be appended to the url to make the // image decode at a size equal to or smaller than 2mp. // // The method Downsample.sizeNoMoreThan(scale) returns a media fragment // object that you can use to reduce the dimensions of an image as much // as possible without exceeding the specified scale. If you have a // 1600x1200 image and want to decode it to produce an image that is as // small as possible but at least 160x120, you would pass a scale of 0.1. // // The returned objects have a dimensionScale property that specifies how // they affect the dimensions of the image and an areaScale property that // specifies how much they affect the area (number of pixels) in an // image. (The areaScale is just the square of the scale.) To avoid // floating-point rounding issues, the values of these scale properties // are rounded to the nearest hundredth. // // The returned objects also have a scale() method that scales a // dimension with proper rounding (it rounds up to the nearest integer // just as libjpeg does). // // Each object also has a toString() method that returns the required // media fragment (including the hash mark) so you can simply use // string concatentation to append one of these objects to the URL of // the image you want to decode. // // Downsample.NONE is a no-op media fragment object with scale set to // 1, and a toString() method that returns the empty string. // (function(exports) { 'use strict'; // jshint ignore:line // Round to the nearest hundredth to combat floating point rounding errors function round(x) { return Math.round(x * 100) / 100; } // // A factory method for returning an object that represents a // #-moz-samplesize media fragment. The use of Math.ceil() in the // scale method is from jpeg_core_output_dimensions() in // media/libjpeg/jdmaster.c and jdiv_round_up() in media/libjpeg/jutils.c // function MozSampleSize(n, scale) { return Object.freeze({ dimensionScale: round(scale), areaScale: round(scale * scale), toString: function() { return '#-moz-samplesize=' + n; }, scale: function(x) { return Math.ceil(x * scale); } }); } // A fragment object that represents no downsampling with no fragment var NONE = Object.freeze({ dimensionScale: 1, areaScale: 1, toString: function() { return ''; }, scale: function(x) { return x; } }); // // The five possible #-moz-samplesize values. // The mapping from sample size to scale comes from: // the moz-samplesize code in /image/decoders/nsJPEGDecoder.cpp and // the jpeg_core_output_dimensions() function in media/libjpeg/jdmaster.c // var fragments = [ NONE, // samplesize=2 reduces size by 1/2 and area by 1/4, etc. MozSampleSize(2, 1 / 2), MozSampleSize(3, 3 / 8), MozSampleSize(4, 1 / 4), MozSampleSize(8, 1 / 8) ]; // Return the fragment object that has the largest scale and downsamples the // dimensions of an image at least as much as the specified scale. // If none of the choices scales enough, return the one that comes closest function sizeAtLeast(scale) { scale = round(scale); for (var i = 0; i < fragments.length; i++) { var f = fragments[i]; if (f.dimensionScale <= scale) { return f; } } return fragments[fragments.length - 1]; } // Return the fragment object that downsamples an image as far as possible // without going beyond the specified scale. This might return NONE. function sizeNoMoreThan(scale) { scale = round(scale); for (var i = fragments.length - 1; i >= 0; i--) { var f = fragments[i]; if (f.dimensionScale >= scale) { return f; } } return NONE; } // Return the fragment object that has the largest scale and downsamples the // area of an image at least as much as the specified scale. // If none of the choices scales enough, return the one that comes closest function areaAtLeast(scale) { scale = round(scale); for (var i = 0; i < fragments.length; i++) { var f = fragments[i]; if (f.areaScale <= scale) { return f; } } return fragments[fragments.length - 1]; } // Return the fragment object that downsamples the area of an image // as far as possible without going beyond the specified scale. This // might return NONE. function areaNoMoreThan(scale) { scale = round(scale); for (var i = fragments.length - 1; i >= 0; i--) { var f = fragments[i]; if (f.areaScale >= scale) { return f; } } return NONE; } exports.Downsample = { sizeAtLeast: sizeAtLeast, sizeNoMoreThan: sizeNoMoreThan, areaAtLeast: areaAtLeast, areaNoMoreThan: areaNoMoreThan, NONE: NONE, MAX_SIZE_REDUCTION: 1 / fragments[fragments.length - 1].dimensionScale, MAX_AREA_REDUCTION: 1 / fragments[fragments.length - 1].areaScale }; }(exports.ImageUtils)); })(window);