diff --git a/docs/api-output.md b/docs/api-output.md index 1d1aa445..06335046 100644 --- a/docs/api-output.md +++ b/docs/api-output.md @@ -154,6 +154,11 @@ Indexed PNG input at 1, 2 or 4 bits per pixel is converted to 8 bits per pixel. - `options.progressive` **[Boolean][6]** use progressive (interlace) scan (optional, default `false`) - `options.compressionLevel` **[Number][8]** zlib compression level, 0-9 (optional, default `9`) - `options.adaptiveFiltering` **[Boolean][6]** use adaptive row filtering (optional, default `false`) + - `options.palette` **[Boolean][6]** quantise to a palette-based image with alpha transparency support, requires libimagequant (optional, default `false`) + - `options.quality` **[Number][8]** use the lowest number of colours needed to achieve given quality, requires libimagequant (optional, default `100`) + - `options.colours` **[Number][8]** maximum number of palette entries, requires libimagequant (optional, default `256`) + - `options.colors` **[Number][8]** alternative spelling of `options.colours`, requires libimagequant (optional, default `256`) + - `options.dither` **[Number][8]** level of Floyd-Steinberg error diffusion, requires libimagequant (optional, default `1.0`) - `options.force` **[Boolean][6]** force PNG output, otherwise attempt to use input format (optional, default `true`) ### Examples diff --git a/docs/changelog.md b/docs/changelog.md index f4f3d47a..e07cc409 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,9 @@ Requires libvips v8.7.0. * Expose `pages` and `pageHeight` metadata for multi-page input images. [#1205](https://github.com/lovell/sharp/issues/1205) +* Expose PNG output options requiring libimagequant. + [#1484](https://github.com/lovell/sharp/issues/1484) + * Expose underlying error message for invalid input. [#1505](https://github.com/lovell/sharp/issues/1505) diff --git a/lib/constructor.js b/lib/constructor.js index 40d0acd5..303c4319 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -171,6 +171,10 @@ const Sharp = function (input, options) { pngProgressive: false, pngCompressionLevel: 9, pngAdaptiveFiltering: false, + pngPalette: false, + pngQuality: 100, + pngColours: 256, + pngDither: 1, webpQuality: 80, webpAlphaQuality: 100, webpLossless: false, diff --git a/lib/output.js b/lib/output.js index f74bfc35..40ad4d0f 100644 --- a/lib/output.js +++ b/lib/output.js @@ -221,6 +221,11 @@ function jpeg (options) { * @param {Boolean} [options.progressive=false] - use progressive (interlace) scan * @param {Number} [options.compressionLevel=9] - zlib compression level, 0-9 * @param {Boolean} [options.adaptiveFiltering=false] - use adaptive row filtering + * @param {Boolean} [options.palette=false] - quantise to a palette-based image with alpha transparency support, requires libimagequant + * @param {Number} [options.quality=100] - use the lowest number of colours needed to achieve given quality, requires libimagequant + * @param {Number} [options.colours=256] - maximum number of palette entries, requires libimagequant + * @param {Number} [options.colors=256] - alternative spelling of `options.colours`, requires libimagequant + * @param {Number} [options.dither=1.0] - level of Floyd-Steinberg error diffusion, requires libimagequant * @param {Boolean} [options.force=true] - force PNG output, otherwise attempt to use input format * @returns {Sharp} * @throws {Error} Invalid options @@ -240,6 +245,33 @@ function png (options) { if (is.defined(options.adaptiveFiltering)) { this._setBooleanOption('pngAdaptiveFiltering', options.adaptiveFiltering); } + if (is.defined(options.palette)) { + this._setBooleanOption('pngPalette', options.palette); + if (this.options.pngPalette) { + if (is.defined(options.quality)) { + if (is.integer(options.quality) && is.inRange(options.quality, 0, 100)) { + this.options.pngQuality = options.quality; + } else { + throw is.invalidParameterError('quality', 'integer between 0 and 100', options.quality); + } + } + const colours = options.colours || options.colors; + if (is.defined(colours)) { + if (is.integer(colours) && is.inRange(colours, 2, 256)) { + this.options.pngColours = colours; + } else { + throw is.invalidParameterError('colours', 'integer between 2 and 256', colours); + } + } + if (is.defined(options.dither)) { + if (is.number(options.dither) && is.inRange(options.dither, 0, 1)) { + this.options.pngDither = options.dither; + } else { + throw is.invalidParameterError('dither', 'number between 0.0 and 1.0', options.dither); + } + } + } + } } return this._updateFormatOut('png', options); } diff --git a/src/pipeline.cc b/src/pipeline.cc index 74383f07..2bc9c568 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -739,7 +739,11 @@ class PipelineWorker : public Nan::AsyncWorker { ->set("strip", !baton->withMetadata) ->set("interlace", baton->pngProgressive) ->set("compression", baton->pngCompressionLevel) - ->set("filter", baton->pngAdaptiveFiltering ? VIPS_FOREIGN_PNG_FILTER_ALL : VIPS_FOREIGN_PNG_FILTER_NONE))); + ->set("filter", baton->pngAdaptiveFiltering ? VIPS_FOREIGN_PNG_FILTER_ALL : VIPS_FOREIGN_PNG_FILTER_NONE) + ->set("palette", baton->pngPalette) + ->set("Q", baton->pngQuality) + ->set("colours", baton->pngColours) + ->set("dither", baton->pngDither))); baton->bufferOut = static_cast(area->data); baton->bufferOutLength = area->length; area->free_fn = nullptr; @@ -849,7 +853,11 @@ class PipelineWorker : public Nan::AsyncWorker { ->set("strip", !baton->withMetadata) ->set("interlace", baton->pngProgressive) ->set("compression", baton->pngCompressionLevel) - ->set("filter", baton->pngAdaptiveFiltering ? VIPS_FOREIGN_PNG_FILTER_ALL : VIPS_FOREIGN_PNG_FILTER_NONE)); + ->set("filter", baton->pngAdaptiveFiltering ? VIPS_FOREIGN_PNG_FILTER_ALL : VIPS_FOREIGN_PNG_FILTER_NONE) + ->set("palette", baton->pngPalette) + ->set("Q", baton->pngQuality) + ->set("colours", baton->pngColours) + ->set("dither", baton->pngDither)); baton->formatOut = "png"; } else if (baton->formatOut == "webp" || (mightMatchInput && isWebp) || (willMatchInput && inputImageType == ImageType::WEBP)) { @@ -1284,6 +1292,10 @@ NAN_METHOD(pipeline) { baton->pngProgressive = AttrTo(options, "pngProgressive"); baton->pngCompressionLevel = AttrTo(options, "pngCompressionLevel"); baton->pngAdaptiveFiltering = AttrTo(options, "pngAdaptiveFiltering"); + baton->pngPalette = AttrTo(options, "pngPalette"); + baton->pngQuality = AttrTo(options, "pngQuality"); + baton->pngColours = AttrTo(options, "pngColours"); + baton->pngDither = AttrTo(options, "pngDither"); baton->webpQuality = AttrTo(options, "webpQuality"); baton->webpAlphaQuality = AttrTo(options, "webpAlphaQuality"); baton->webpLossless = AttrTo(options, "webpLossless"); diff --git a/src/pipeline.h b/src/pipeline.h index 0c05a42e..4dee6cad 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -115,6 +115,10 @@ struct PipelineBaton { bool pngProgressive; int pngCompressionLevel; bool pngAdaptiveFiltering; + bool pngPalette; + int pngQuality; + int pngColours; + double pngDither; int webpQuality; int webpAlphaQuality; bool webpNearLossless; @@ -216,6 +220,10 @@ struct PipelineBaton { pngProgressive(false), pngCompressionLevel(9), pngAdaptiveFiltering(false), + pngPalette(false), + pngQuality(100), + pngColours(256), + pngDither(1.0), webpQuality(80), tiffQuality(80), tiffCompression(VIPS_FOREIGN_TIFF_COMPRESSION_JPEG), diff --git a/test/unit/io.js b/test/unit/io.js index 2338501a..f5507dd6 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -628,78 +628,6 @@ describe('Input/output', function () { }); }); - describe('PNG output', function () { - it('compression level is valid', function () { - assert.doesNotThrow(function () { - sharp().png({ compressionLevel: 0 }); - }); - }); - - it('compression level is invalid', function () { - assert.throws(function () { - sharp().png({ compressionLevel: -1 }); - }); - }); - - it('default compressionLevel generates smaller file than compressionLevel=6', function (done) { - // First generate with default compressionLevel - sharp(fixtures.inputPng) - .resize(320, 240) - .png() - .toBuffer(function (err, defaultData, defaultInfo) { - if (err) throw err; - assert.strictEqual(true, defaultData.length > 0); - assert.strictEqual('png', defaultInfo.format); - // Then generate with compressionLevel=6 - sharp(fixtures.inputPng) - .resize(320, 240) - .png({ compressionLevel: 6 }) - .toBuffer(function (err, largerData, largerInfo) { - if (err) throw err; - assert.strictEqual(true, largerData.length > 0); - assert.strictEqual('png', largerInfo.format); - assert.strictEqual(true, defaultData.length < largerData.length); - done(); - }); - }); - }); - - it('without adaptiveFiltering generates smaller file', function (done) { - // First generate with adaptive filtering - sharp(fixtures.inputPng) - .resize(320, 240) - .png({ adaptiveFiltering: true }) - .toBuffer(function (err, adaptiveData, adaptiveInfo) { - if (err) throw err; - assert.strictEqual(true, adaptiveData.length > 0); - assert.strictEqual(adaptiveData.length, adaptiveInfo.size); - assert.strictEqual('png', adaptiveInfo.format); - assert.strictEqual(320, adaptiveInfo.width); - assert.strictEqual(240, adaptiveInfo.height); - // Then generate without - sharp(fixtures.inputPng) - .resize(320, 240) - .png({ adaptiveFiltering: false }) - .toBuffer(function (err, withoutAdaptiveData, withoutAdaptiveInfo) { - if (err) throw err; - assert.strictEqual(true, withoutAdaptiveData.length > 0); - assert.strictEqual(withoutAdaptiveData.length, withoutAdaptiveInfo.size); - assert.strictEqual('png', withoutAdaptiveInfo.format); - assert.strictEqual(320, withoutAdaptiveInfo.width); - assert.strictEqual(240, withoutAdaptiveInfo.height); - assert.strictEqual(true, withoutAdaptiveData.length < adaptiveData.length); - done(); - }); - }); - }); - - it('Invalid PNG adaptiveFiltering value throws error', function () { - assert.throws(function () { - sharp().png({ adaptiveFiltering: 1 }); - }); - }); - }); - it('Without chroma subsampling generates larger file', function (done) { // First generate with chroma subsampling (default) sharp(fixtures.inputJpg) diff --git a/test/unit/png.js b/test/unit/png.js new file mode 100644 index 00000000..652b8b7b --- /dev/null +++ b/test/unit/png.js @@ -0,0 +1,145 @@ +'use strict'; + +const fs = require('fs'); +const assert = require('assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +describe('PNG output', function () { + it('compression level is valid', function () { + assert.doesNotThrow(function () { + sharp().png({ compressionLevel: 0 }); + }); + }); + + it('compression level is invalid', function () { + assert.throws(function () { + sharp().png({ compressionLevel: -1 }); + }); + }); + + it('default compressionLevel generates smaller file than compressionLevel=6', function (done) { + // First generate with default compressionLevel + sharp(fixtures.inputPng) + .resize(320, 240) + .png() + .toBuffer(function (err, defaultData, defaultInfo) { + if (err) throw err; + assert.strictEqual(true, defaultData.length > 0); + assert.strictEqual('png', defaultInfo.format); + // Then generate with compressionLevel=6 + sharp(fixtures.inputPng) + .resize(320, 240) + .png({ compressionLevel: 6 }) + .toBuffer(function (err, largerData, largerInfo) { + if (err) throw err; + assert.strictEqual(true, largerData.length > 0); + assert.strictEqual('png', largerInfo.format); + assert.strictEqual(true, defaultData.length < largerData.length); + done(); + }); + }); + }); + + it('without adaptiveFiltering generates smaller file', function (done) { + // First generate with adaptive filtering + sharp(fixtures.inputPng) + .resize(320, 240) + .png({ adaptiveFiltering: true }) + .toBuffer(function (err, adaptiveData, adaptiveInfo) { + if (err) throw err; + assert.strictEqual(true, adaptiveData.length > 0); + assert.strictEqual(adaptiveData.length, adaptiveInfo.size); + assert.strictEqual('png', adaptiveInfo.format); + assert.strictEqual(320, adaptiveInfo.width); + assert.strictEqual(240, adaptiveInfo.height); + // Then generate without + sharp(fixtures.inputPng) + .resize(320, 240) + .png({ adaptiveFiltering: false }) + .toBuffer(function (err, withoutAdaptiveData, withoutAdaptiveInfo) { + if (err) throw err; + assert.strictEqual(true, withoutAdaptiveData.length > 0); + assert.strictEqual(withoutAdaptiveData.length, withoutAdaptiveInfo.size); + assert.strictEqual('png', withoutAdaptiveInfo.format); + assert.strictEqual(320, withoutAdaptiveInfo.width); + assert.strictEqual(240, withoutAdaptiveInfo.height); + assert.strictEqual(true, withoutAdaptiveData.length < adaptiveData.length); + done(); + }); + }); + }); + + it('Invalid PNG adaptiveFiltering value throws error', function () { + assert.throws(function () { + sharp().png({ adaptiveFiltering: 1 }); + }); + }); + + it('Valid PNG libimagequant palette value does not throw error', function () { + assert.doesNotThrow(function () { + sharp().png({ palette: false }); + }); + }); + + it('Invalid PNG libimagequant palette value throws error', function () { + assert.throws(function () { + sharp().png({ palette: 'fail' }); + }); + }); + + it('Valid PNG libimagequant quality value produces image of same size or smaller', function () { + const inputPngBuffer = fs.readFileSync(fixtures.inputPng); + return Promise.all([ + sharp(inputPngBuffer).resize(10).png({ palette: true, quality: 80 }).toBuffer(), + sharp(inputPngBuffer).resize(10).png({ palette: true, quality: 100 }).toBuffer() + ]).then(function (data) { + assert.strictEqual(true, data[0].length <= data[1].length); + }); + }); + + it('Invalid PNG libimagequant quality value throws error', function () { + assert.throws(function () { + sharp().png({ palette: true, quality: 101 }); + }); + }); + + it('Valid PNG libimagequant colours value produces image of same size or smaller', function () { + const inputPngBuffer = fs.readFileSync(fixtures.inputPng); + return Promise.all([ + sharp(inputPngBuffer).resize(10).png({ palette: true, colours: 100 }).toBuffer(), + sharp(inputPngBuffer).resize(10).png({ palette: true, colours: 200 }).toBuffer() + ]).then(function (data) { + assert.strictEqual(true, data[0].length <= data[1].length); + }); + }); + + it('Invalid PNG libimagequant colours value throws error', function () { + assert.throws(function () { + sharp().png({ palette: true, colours: -1 }); + }); + }); + + it('Invalid PNG libimagequant colors value throws error', function () { + assert.throws(function () { + sharp().png({ palette: true, colors: 0.1 }); + }); + }); + + it('Valid PNG libimagequant dither value produces image of same size or smaller', function () { + const inputPngBuffer = fs.readFileSync(fixtures.inputPng); + return Promise.all([ + sharp(inputPngBuffer).resize(10).png({ palette: true, dither: 0.1 }).toBuffer(), + sharp(inputPngBuffer).resize(10).png({ palette: true, dither: 0.9 }).toBuffer() + ]).then(function (data) { + assert.strictEqual(true, data[0].length <= data[1].length); + }); + }); + + it('Invalid PNG libimagequant dither value throws error', function () { + assert.throws(function () { + sharp().png({ palette: true, dither: 'fail' }); + }); + }); +});