UNPKG

pngjs-image

Version:

JavaScript-based PNG image encoder, decoder, and manipulator

826 lines (701 loc) 19.7 kB
// Copyright 2015 Yahoo! Inc. // Copyrights licensed under the Mit License. See the accompanying LICENSE file for terms. // IHDR - Image header var colorTypes = require('../utils/constants').colorTypes; var BufferedStream = require('../utils/bufferedStream'); var Compressor = require('../processor/compressor'); var Filter = require('../processor/filter'); var Interlace = require('../processor/interlace'); var Parser = require('../processor/parser'); var Normalizer = require('../processor/normalizer'); var Scaler = require('../processor/scaler'); /** * @class IHDR * @module PNG * @submodule PNGChunks */ module.exports = { /** * Gets the sequence * * @method getSequence * @return {int} */ getSequence: function () { return 0; }, /** * Gets the width of the image * * @method getWidth * @return {int} */ getWidth: function () { return this._width; }, /** * Sets the width of the image * * @method setWidth * @param {int} width */ setWidth: function (width) { if (width <= 0) { throw new Error('Width has to be greater than zero.'); } this._width = width; }, /** * Gets the height of the image * * @method getHeight * @return {int} */ getHeight: function () { return this._height; }, /** * Sets the height of the image * * @method setHeight * @param {int} height */ setHeight: function (height) { if (height <= 0) { throw new Error('Height has to be greater than zero.'); } this._height = height; }, /** * Gets the bit-depth of the image data * * @method getBitDepth * @return {int} */ getBitDepth: function () { return this._bitDepth; }, /** * Sets the bit-depth of the image data * * @method setBitDepth * @param {int} bitDepth */ setBitDepth: function (bitDepth) { if ([1, 2, 4, 8, 16].indexOf(bitDepth) === -1) { throw new Error('Unknown bit-depth of ' + bitDepth + '.'); } this._bitDepth = bitDepth; }, /** * Gets the color-type of the image data * * @method getColorType * @return {int} */ getColorType: function () { return this._colorType; }, /** * Sets the color-type of the image data * * @method setColorType * @param {int} colorType */ setColorType: function (colorType) { if ([colorTypes.GREY_SCALE, colorTypes.TRUE_COLOR, colorTypes.INDEXED_COLOR, colorTypes.GREY_SCALE_ALPHA, colorTypes.TRUE_COLOR_ALPHA].indexOf(colorType) === -1) { throw new Error('Unknown color-type ' + colorType + '.'); } this._colorType = colorType; }, /** * Gets the compression method of the image data * * @method getCompressionMethod * @return {int} */ getCompressionMethod: function () { return this._compressionMethod; }, /** * Sets the compression method of the image data * * @method setCompressionMethod * @param {int} method */ setCompressionMethod: function (method) { if (method !== 0) { throw new Error('Unsupported compression method with identifier ' + method + '.'); } this._compressionMethod = method; }, /** * Gets the filter method of the image data * * @method getFilterMethod * @return {int} */ getFilterMethod: function () { return this._filterMethod; }, /** * Sets the filter method of the image data * * @method setFilterMethod * @param {int} method */ setFilterMethod: function (method) { if (method !== 0) { throw new Error('Unsupported filter method with identifier ' + method + '.'); } this._filterMethod = method; }, /** * Gets the interlace method of the image data * * @method getInterlaceMethod * @return {int} */ getInterlaceMethod: function () { return this._interlaceMethod; }, /** * Sets the interlace method of the image data * * @method setInterlaceMethod * @param {int} method */ setInterlaceMethod: function (method) { if ([0, 1].indexOf(method) === -1) { throw new Error('Unsupported interlace method with identifier ' + method + '.'); } this._interlaceMethod = method; }, /** * Does image have a an indexed color palette? * * @method hasIndexedColor * @return {boolean} */ hasIndexedColor: function () { return ((this._colorType & 1) === 1); }, /** * Is image in color? * * @method isColor * @return {boolean} */ isColor: function () { return ((this._colorType & 2) === 2); }, /** * Does image have an alpha-chanel? * * @method hasAlphaChannel * @return {boolean} */ hasAlphaChannel: function () { return ((this._colorType & 4) === 4); }, /** * Is the image interlaced? * * @method isInterlaced * @return {boolean} */ isInterlaced: function () { return this.getInterlaceMethod() !== 0; }, /** * Determines bytes per pixel * * @method getBytesPerPixel * @return {int} */ getBytesPerPixel: function () { var bitDepth = this.getBitDepth(); return (bitDepth / 8) * this.getUnprocessedSamples(); }, /** * Gets the number of samples for the color-type * * @method getSamples * @return {int} */ getSamples: function () { return this.hasIndexedColor() ? 3 : (this.isColor() ? 3 : 1) + (this.hasAlphaChannel() ? 1 : 0); }, /** * Gets the number of samples for the color-type (unprocessed - palette not applied yet) * * @method getUnprocessedSamples * @return {int} */ getUnprocessedSamples: function () { return this.hasIndexedColor() ? 1 : this.getSamples(); }, /** * Gets the sample-depth for the color-type * * @method getSampleDepth * @return {int} */ getSampleDepth: function () { return this.hasIndexedColor() ? 8 : this.getBitDepth(); }, /** * Determines the scan-line length * * @method getScanLineLength * @return {int} */ getScanLineLength: function () { return this.getScanLineLengthForWidth(this.getWidth()); }, /** * Determines the scan-line length for a specified width * * @method getScanLineLengthForWidth * @param {int} width * @return {int} */ getScanLineLengthForWidth: function (width) { return this.getBytesPerPixel() * width; }, /** * Determines if the data requires padding for scanlines * * @method hasScanLinePadding * @return {boolean} */ hasScanLinePadding: function () { return this.hasScanLinePaddingWithWidth(this.getWidth()); }, /** * Determines if the data requires padding for scanlines * * @method hasScanLinePaddingWithWidth * @param {int} width * @return {boolean} */ hasScanLinePaddingWithWidth: function (width) { var scanLineLength = this.getScanLineLengthForWidth(width); return (scanLineLength != Math.ceil(scanLineLength)); }, /** * Determines position of scanline pixel * * @method scanLinePaddingAt * @return {int} */ scanLinePaddingAt: function () { return this.scanLineWithWidthPaddingAt(this.getWidth()); }, /** * Determines position of scanline pixel with width * * @method scanLinePaddingAt * @param {int} width * @return {int} */ scanLineWithWidthPaddingAt: function (width) { if (this.hasScanLinePaddingWithWidth(width)) { return this.getUnprocessedSamples() * width; } else { return null; } }, /** * Gets the size of the image in bytes during edit-mode * * @method getImageSizeInBytes * @return {int} */ getImageSizeInBytes: function () { return this.getWidth() * this.getHeight() * this.getImageBytesPerPixel(); }, /** * Gets the number of bytes in a pixel for images after scaling * * @method getImageBytesPerPixel * @return {int} */ getImageBytesPerPixel: function () { return 4; // This is the working bpp for PNGjs-image }, /** * Is the image of color-type "Grayscale"? * * @method isColorTypeGreyScale * @return {boolean} */ isColorTypeGreyScale: function () { return this.getColorType() === colorTypes.GREY_SCALE; }, /** * Is the image of color-type "True-color"? * * @method isColorTypeTrueColor * @return {boolean} */ isColorTypeTrueColor: function () { return this.getColorType() === colorTypes.TRUE_COLOR; }, /** * Is the image of color-type "Indexed-color"? * * @method isColorTypeIndexedColor * @return {boolean} */ isColorTypeIndexedColor: function () { return this.getColorType() === colorTypes.INDEXED_COLOR; }, /** * Is the image of color-type "Grayscale with alpha channel"? * * @method isColorTypeGreyScaleWithAlpha * @return {boolean} */ isColorTypeGreyScaleWithAlpha: function () { return this.getColorType() === colorTypes.GREY_SCALE_ALPHA; }, /** * Is the image of color-type "True-color with alpha channel"? * * @method isColorTypeTrueColorWithAlpha * @return {boolean} */ isColorTypeTrueColorWithAlpha: function () { return this.getColorType() === colorTypes.TRUE_COLOR_ALPHA; }, /** * Gets the dimensions of the image * * @method getDimensions * @return {int} */ getDimensions: function () { return this.getWidth() * this.getHeight(); }, /** * Parsing of chunk data * * Phase 1 * * @method parse * @param {BufferedStream} stream Data stream * @param {int} length Length of chunk data * @param {boolean} strict Should parsing be strict? * @param {object} options Decoding options */ parse: function (stream, length, strict, options) { var maxWidth, maxHeight, maxDim, maxSize; // Validation if ((strict && (length !== 13)) || (length < 13)) { throw new Error('Invalid length of header. Length: ' + length); } if (strict && (this.getFirstChunk(this.getType(), false) !== null)) { throw new Error('Only one ' + this.getType() + ' is allowed in the data.'); } // Read and set values (+ validation) this.setWidth(stream.readUInt32BE()); this.setHeight(stream.readUInt32BE()); this.setBitDepth(stream.readUInt8()); this.setColorType(stream.readUInt8()); this.setCompressionMethod(stream.readUInt8()); this.setFilterMethod(stream.readUInt8()); this.setInterlaceMethod(stream.readUInt8()); // Check bit-depth and color-types combination if ((this._colorType === colorTypes.GREY_SCALE) && ([1, 2, 4, 8, 16].indexOf(this._bitDepth) === -1)) { throw new Error('Header error: Unsupported bit-depth for GrayScale images.'); } if ((this._colorType === colorTypes.TRUE_COLOR) && ([8, 16].indexOf(this._bitDepth) === -1)) { throw new Error('Header error: Unsupported bit-depth for TrueColor images.'); } if ((this._colorType === colorTypes.INDEXED_COLOR) && ([1, 2, 4, 8].indexOf(this._bitDepth) === -1)) { throw new Error('Header error: Unsupported bit-depth for Indexed-color images.'); } if ((this._colorType === colorTypes.GREY_SCALE_ALPHA) && ([8, 16].indexOf(this._bitDepth) === -1)) { throw new Error('Header error: Unsupported bit-depth for GrayScale with alpha-channel images.'); } if ((this._colorType === colorTypes.TRUE_COLOR_ALPHA) && ([8, 16].indexOf(this._bitDepth) === -1)) { throw new Error('Header error: Unsupported bit-depth for TrueColor with alpha-channel images.'); } // Check for de-compression bombs maxWidth = (options.maxWidth !== undefined) ? options.maxWidth : 0; if (options.checkBombs && (maxWidth !== 0) && (this.width > maxWidth)) { throw new Error('Image width is larger than allowed.'); } maxHeight = (options.maxHeight !== undefined) ? options.maxHeight : 0; if (options.checkBombs && (maxHeight !== 0) && (this.height > maxHeight)) { throw new Error('Image height is larger than allowed.'); } maxDim = (options.maxDim !== undefined) ? options.maxDim : 0; if (options.checkBombs && (maxDim !== 0) && (this.getDimensions() > maxDim)) { throw new Error('Image resolution is larger than allowed.'); } maxSize = (options.maxSize !== undefined) ? options.maxSize : 16 * 1024 * 1024; if (options.checkBombs && (maxSize !== 0) && (this.getImageSizeInBytes() > maxSize)) { throw new Error('Image size in byte is larger than allowed.'); } }, /** * Decoding of chunk data before scaling * * Phase 2 * * @method decode * @param {Buffer} image * @param {boolean} strict Should parsing be strict? * @param {object} options Decoding options * @return {Buffer} */ decode: function (image, strict, options) { var compressor, filter, parser, normalizer, localImage; // Combine all data chunks localImage = this._combine(); // Decompress compressor = new Compressor(options); localImage = compressor.decode(localImage); // Run through filters filter = new Filter(this, options); localImage = filter.decode(localImage); // Parses scanlines parser = new Parser(this, options); localImage = parser.decode(localImage); // Normalizes color values normalizer = new Normalizer(this, options); localImage = normalizer.decode(localImage); // Ignoring the incoming values - this is the first chunk creating these return localImage; }, /** * Decoding of chunk data after scaling * * Phase 3 * * @method postDecode * @param {Buffer} image * @param {boolean} strict Should parsing be strict? * @param {object} options Decoding options * @return {Buffer} */ postDecode: function (image, strict, options) { var scaler, interlace, localImage = image; scaler = new Scaler(this, options); localImage = scaler.decode(localImage); // Run through interlace method interlace = new Interlace(this, options); localImage = interlace.decode(localImage); return localImage; }, /** * Gathers chunk-data from decoded chunks * * Phase 5 * * @static * @method decodeData * @param {object} data Data-object that will be used to export values * @param {boolean} strict Should parsing be strict? * @param {object} options Decoding options */ decodeData: function (data, strict, options) { var chunks = this.getChunksByType(this.getType()); if (!chunks) { throw new Error('Cannot find header.'); } if (strict && (chunks.length !== 1)) { throw new Error('Not more than one chunk allowed for ' + this.getType() + '.'); } data.volatile = data.volatile || {}; data.volatile.header = { width: chunks[0].getWidth(), height: chunks[0].getHeight(), bitDepth: chunks[0].getBitDepth(), colorType: chunks[0].getColorType(), compression: chunks[0].getCompressionMethod(), filter: chunks[0].getFilterMethod(), interlace: chunks[0].getInterlaceMethod() }; }, /** * Combines all IDAT chunks into on buffer * * @method _combine * @return {Buffer} * @private */ _combine: function () { var totalLength = 0, dataChunks = this.getChunksByType('IDAT', true), combinedStream; // Determine length dataChunks.forEach(function (dataChunk) { totalLength += dataChunk.getStream().length; }); // Combine all the blobs combinedStream = new BufferedStream(null, null, totalLength); dataChunks.forEach(function (dataChunk) { combinedStream.writeBufferedStream(dataChunk.getStream()); }); return combinedStream.toBuffer(true); }, /** * Returns a list of chunks to be added to the data-stream * * Phase 1 * * @static * @method encodeData * @param {Buffer} image Image data * @param {object} options Encoding options * @return {Chunk[]} List of chunks to encode */ encodeData: function (image, options) { var chunk = this.createChunk(this.getType(), this.getChunks()); chunk.setWidth(options.header.width); chunk.setHeight(options.header.height); chunk.setBitDepth(options.header.bitDepth); chunk.setColorType(options.header.colorType); chunk.setCompressionMethod(options.header.compression); chunk.setFilterMethod(options.header.filter); chunk.setInterlaceMethod(options.header.interlace); return [chunk]; }, /** * Before encoding of chunk data * * Phase 2 * * Note: * Use this method to gather image-data before scaling. * * @method preEncode * @param {Buffer} image * @param {object} options Encoding options * @return {Buffer} */ preEncode: function (image, options) { var imageStream, scaledStream, reducedStream, withdrawnStream, interlace; // Run through interlace method interlace = new Interlace(this); image = interlace.encode(image, options); if (this.isColorTypeIndexedColor()) { // We expect the PLTE chunk to do the work return image; } //TODO refactor like in decode //TODO: Padding imageStream = new BufferedStream(image, false); // Add alpha channel if needed if (!this.hasAlphaChannel()) { withdrawnStream = new BufferedStream(null, null, imageStream.length * 0.75); //scale.withdraw(imageStream, withdrawnStream, 4); } else { withdrawnStream = imageStream; // Nothing to do } // Channel spreading - Grayscale to color if (!this.isColor()) { reducedStream = new BufferedStream(null, null, withdrawnStream.length / 3); //scale.reduce(withdrawnStream, reducedStream, 3); } else { reducedStream = withdrawnStream; // Nothing to do } // Do some scaling if (this.getBitDepth() === 1) { scaledStream = new BufferedStream(null, null, reducedStream.length / 8); //scale.scale8to1bit(reducedStream, scaledStream); } else if (this.getBitDepth() === 2) { scaledStream = new BufferedStream(null, null, reducedStream.length / 4); //scale.scale8to2bit(reducedStream, scaledStream); } else if (this.getBitDepth() === 4) { scaledStream = new BufferedStream(null, null, reducedStream.length / 2); //scale.scale8to4bit(reducedStream, scaledStream); } else if (this.getBitDepth() === 16) { scaledStream = new BufferedStream(null, null, reducedStream.length * 2); //scale.scale8to16bit(reducedStream, scaledStream); } else { scaledStream = reducedStream; // Nothing to do } return scaledStream.toBuffer(true); }, /** * Encoding of chunk data * * Phase 3 * * Note: * Use this method to add data to the image after scaling. * * @method encode * @param {Buffer} image * @param {object} options Encoding options * @return {Buffer} */ encode: function (image, options) { var compressor, filter; // Run through filters filter = new Filter(this, options); image = filter.encode(image); // Compress compressor = new Compressor(options); image = compressor.encode(image); // Separate image to data chunks this._separate(image, options); // Finished since this is the last chunk return null; }, /** * Composing of chunk data * * Phase 4 * * @method compose * @param {BufferedStream} stream Data stream * @param {object} options Encoding options */ compose: function (stream, options) { stream.writeUInt32BE(this.getWidth()); stream.writeUInt32BE(this.getHeight()); stream.writeUInt8(this.getBitDepth()); stream.writeUInt8(this.getColorType()); stream.writeUInt8(this.getCompressionMethod()); stream.writeUInt8(this.getFilterMethod()); stream.writeUInt8(this.getInterlaceMethod()); }, /** * Separates buffer into multiple IDAT * * @method _separate * @param {Buffer} buffer * @param {object} options Encoding options * @param {int} [options.chunkSize=8192] Max. size of the IDAT chunk * @private */ _separate: function (buffer, options) { var chunkLength = options.chunkSize || 8192, chunkQuantity = Math.ceil(buffer.length / chunkLength), Chunk = require('../chunk'), chunk, stream = new BufferedStream(buffer), lastChunkLength, i; for (i = 0; i < chunkQuantity; i++) { chunk = new Chunk('IDAT', this._chunks); lastChunkLength = Math.min(stream.length, chunkLength); chunk.setStream(stream.slice(0, lastChunkLength)); stream.readOffset += lastChunkLength; // Set sequence relative to other data chunks chunk._sequence = chunk.getSequence() + (i / chunkQuantity); this.addChunk(chunk); } } };