yahoo/pngjs-image

View on GitHub
lib/png/chunks/IHDR.js

Summary

Maintainability
D
2 days
Test Coverage
// 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);
        }
    }
};