diff --git a/lib/constructor.js b/lib/constructor.js index 50d3e105..96d6c0b7 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -169,6 +169,8 @@ const Sharp = function (input, options) { webpLossless: false, webpNearLossless: false, tiffQuality: 80, + tiffCompression: 'jpeg', + tiffPredictor: 'none', tileSize: 256, tileOverlap: 0, // Function to notify of queue length changes diff --git a/lib/output.js b/lib/output.js index 75ae221a..2e63f53f 100644 --- a/lib/output.js +++ b/lib/output.js @@ -211,6 +211,8 @@ const webp = function webp (options) { * @param {Object} [options] - output options * @param {Number} [options.quality=80] - quality, integer 1-100 * @param {Boolean} [options.force=true] - force TIFF output, otherwise attempt to use input format + * @param {Boolean} [options.compression='jpeg'] - compression options: lzw, deflate, jpeg + * @param {Boolean} [options.predictor='none'] - compression predictor options: none, horizontal, float * @returns {Sharp} * @throws {Error} Invalid options */ @@ -222,6 +224,24 @@ const tiff = function tiff (options) { throw new Error('Invalid quality (integer, 1-100) ' + options.quality); } } + // compression + if (is.defined(options) && is.defined(options.compression)) { + if (is.string(options.compression) && is.inArray(options.compression, ['lzw', 'deflate', 'jpeg', 'none'])) { + this.options.tiffCompression = options.compression; + } else { + const message = `Invalid compression option "${options.compression}". Should be one of: lzw, deflate, jpeg, none`; + throw new Error(message); + } + } + // predictor + if (is.defined(options) && is.defined(options.predictor)) { + if (is.string(options.predictor) && is.inArray(options.predictor, ['none', 'horizontal', 'float'])) { + this.options.tiffPredictor = options.predictor; + } else { + const message = `Invalid predictor option "${options.predictor}". Should be one of: none, horizontal, float`; + throw new Error(message); + } + } return this._updateFormatOut('tiff', options); }; diff --git a/src/pipeline.cc b/src/pipeline.cc index 50a95fd2..c20e8883 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -842,7 +842,8 @@ class PipelineWorker : public Nan::AsyncWorker { image.tiffsave(const_cast(baton->fileOut.data()), VImage::option() ->set("strip", !baton->withMetadata) ->set("Q", baton->tiffQuality) - ->set("compression", VIPS_FOREIGN_TIFF_COMPRESSION_JPEG)); + ->set("compression", baton->tiffCompression) + ->set("predictor", baton->tiffPredictor) ); baton->formatOut = "tiff"; baton->channels = std::min(baton->channels, 3); } else if (baton->formatOut == "dz" || isDz || isDzZip) { @@ -1199,6 +1200,14 @@ NAN_METHOD(pipeline) { baton->webpLossless = AttrTo(options, "webpLossless"); baton->webpNearLossless = AttrTo(options, "webpNearLossless"); baton->tiffQuality = AttrTo(options, "tiffQuality"); + // tiff compression options + baton->tiffCompression = static_cast( + vips_enum_from_nick(nullptr, VIPS_TYPE_FOREIGN_TIFF_COMPRESSION, + AttrAsStr(options, "tiffCompression").data())); + baton->tiffPredictor = static_cast( + vips_enum_from_nick(nullptr, VIPS_TYPE_FOREIGN_TIFF_PREDICTOR, + AttrAsStr(options, "tiffPredictor").data())); + // Tile output baton->tileSize = AttrTo(options, "tileSize"); baton->tileOverlap = AttrTo(options, "tileOverlap"); diff --git a/src/pipeline.h b/src/pipeline.h index f4bd0484..029750a8 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -104,6 +104,8 @@ struct PipelineBaton { bool webpNearLossless; bool webpLossless; int tiffQuality; + VipsForeignTiffCompression tiffCompression; + VipsForeignTiffPredictor tiffPredictor; std::string err; bool withMetadata; int withMetadataOrientation; @@ -172,6 +174,8 @@ struct PipelineBaton { pngAdaptiveFiltering(true), webpQuality(80), tiffQuality(80), + tiffCompression(VIPS_FOREIGN_TIFF_COMPRESSION_JPEG), + tiffPredictor(VIPS_FOREIGN_TIFF_PREDICTOR_NONE), withMetadata(false), withMetadataOrientation(-1), convKernelWidth(0), diff --git a/test/fixtures/index.js b/test/fixtures/index.js index d6b321e7..8467fc0d 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -84,6 +84,7 @@ module.exports = { inputWebPWithTransparency: getPath('5_webp_a.webp'), // http://www.gstatic.com/webp/gallery3/5_webp_a.webp inputTiff: getPath('G31D.TIF'), // http://www.fileformat.info/format/tiff/sample/e6c9a6e5253348f4aef6d17b534360ab/index.htm inputTiffCielab: getPath('cielab-dagams.tiff'), // https://github.com/lovell/sharp/issues/646 + inputTiffUncompressed: getPath('uncompressed_tiff.tiff'), // https://code.google.com/archive/p/imagetestsuite/wikis/TIFFTestSuite.wiki file: 0c84d07e1b22b76f24cccc70d8788e4a.tif inputGif: getPath('Crash_test.gif'), // http://upload.wikimedia.org/wikipedia/commons/e/e3/Crash_test.gif inputGifGreyPlusAlpha: getPath('grey-plus-alpha.gif'), // http://i.imgur.com/gZ5jlmE.gif inputSvg: getPath('check.svg'), // http://dev.w3.org/SVG/tools/svgweb/samples/svg-files/check.svg @@ -102,6 +103,7 @@ module.exports = { outputPng: getPath('output.png'), outputWebP: getPath('output.webp'), outputV: getPath('output.v'), + outputTiff: getPath('output.tiff'), outputZoinks: getPath('output.zoinks'), // an 'unknown' file extension // Path for tests requiring human inspection diff --git a/test/fixtures/uncompressed_tiff.tiff b/test/fixtures/uncompressed_tiff.tiff new file mode 100644 index 00000000..3b9529df Binary files /dev/null and b/test/fixtures/uncompressed_tiff.tiff differ diff --git a/test/unit/io.js b/test/unit/io.js index 772d944e..2a31d5a9 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -861,6 +861,130 @@ describe('Input/output', function () { }); }); + it('TIFF lzw compression with horizontal predictor shrinks test file', function (done) { + const startSize = fs.statSync(fixtures.inputTiffUncompressed).size; + sharp(fixtures.inputTiffUncompressed) + .tiff({ + compression: 'lzw', + force: true, + // note: lzw compression is imperfect and sometimes + // generates larger files, as it does with this input + // if no predictor is used. + predictor: 'horizontal' + }) + .toFile(fixtures.outputTiff, (err, info) => { + if (err) throw err; + assert.strictEqual('tiff', info.format); + assert(info.size < startSize); + fs.unlinkSync(fixtures.outputTiff); + done(); + }); + }); + + it('TIFF deflate compression with hoizontal predictor shrinks test file', function (done) { + const startSize = fs.statSync(fixtures.inputTiffUncompressed).size; + sharp(fixtures.inputTiffUncompressed) + .tiff({ + compression: 'deflate', + force: true, + predictor: 'horizontal' + }) + .toFile(fixtures.outputTiff, (err, info) => { + if (err) throw err; + assert.strictEqual('tiff', info.format); + assert(info.size < startSize); + fs.unlinkSync(fixtures.outputTiff); + done(); + }); + }); + + it('TIFF deflate compression without predictor shrinks test file', function (done) { + const startSize = fs.statSync(fixtures.inputTiffUncompressed).size; + sharp(fixtures.inputTiffUncompressed) + .tiff({ + compression: 'deflate', + force: true, + predictor: 'none' + }) + .toFile(fixtures.outputTiff, (err, info) => { + if (err) throw err; + assert.strictEqual('tiff', info.format); + assert(info.size < startSize); + fs.unlinkSync(fixtures.outputTiff); + done(); + }); + }); + + it('TIFF jpeg compression shrinks test file', function (done) { + const startSize = fs.statSync(fixtures.inputTiffUncompressed).size; + sharp(fixtures.inputTiffUncompressed) + .tiff({ + compression: 'jpeg', + force: true + }) + .toFile(fixtures.outputTiff, (err, info) => { + if (err) throw err; + assert.strictEqual('tiff', info.format); + assert(info.size < startSize); + fs.unlinkSync(fixtures.outputTiff); + done(); + }); + }); + + it('TIFF none compression does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ compression: 'none' }); + }); + }); + + it('TIFF lzw compression does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ compression: 'lzw' }); + }); + }); + + it('TIFF deflate compression does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ compression: 'deflate' }); + }); + }); + + it('TIFF invalid compression option throws', function () { + assert.throws(function () { + sharp().tiff({ compression: 0 }); + }); + }); + + it('TIFF invalid compression option throws', function () { + assert.throws(function () { + sharp().tiff({ compression: 'a' }); + }); + }); + + it('TIFF invalid predictor option throws', function () { + assert.throws(function () { + sharp().tiff({ predictor: 'a' }); + }); + }); + + it('TIFF horizontal predictor does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ predictor: 'horizontal' }); + }); + }); + + it('TIFF float predictor does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ predictor: 'float' }); + }); + }); + + it('TIFF none predictor does not throw error', function () { + assert.doesNotThrow(function () { + sharp().tiff({ predictor: 'none' }); + }); + }); + it('Input and output formats match when not forcing', function (done) { sharp(fixtures.inputJpg) .resize(320, 240)