diff --git a/docs/api-output.md b/docs/api-output.md index 391daf66..538b7f90 100644 --- a/docs/api-output.md +++ b/docs/api-output.md @@ -330,6 +330,47 @@ The prebuilt binaries do not include this - see Returns **Sharp** +## jp2 + +Use these JP2 options for output image. + +Requires libvips compiled with support for OpenJPEG. +The prebuilt binaries do not include this - see +[installing a custom libvips][11]. + +### Parameters + +* `options` **[Object][6]?** output options + + * `options.quality` **[number][9]** quality, integer 1-100 (optional, default `80`) + * `options.lossless` **[boolean][7]** use lossless compression mode (optional, default `false`) + * `options.tileWidth` **[number][9]** horizontal tile size (optional, default `512`) + * `options.tileHeight` **[number][9]** vertical tile size (optional, default `512`) + * `options.chromaSubsampling` **[string][2]** set to '4:2:0' to use chroma subsampling (optional, default `'4:4:4'`) + +### Examples + +```javascript +// Convert any input to lossless JP2 output +const data = await sharp(input) + .jp2({ lossless: true }) + .toBuffer(); +``` + +```javascript +// Convert any input to very high quality JP2 output +const data = await sharp(input) + .jp2({ + quality: 100, + chromaSubsampling: '4:4:4' + }) + .toBuffer(); +``` + +* Throws **[Error][4]** Invalid options + +Returns **Sharp** + ## tiff Use these TIFF options for output image. diff --git a/lib/constructor.js b/lib/constructor.js index 661915b7..711221f2 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -235,6 +235,11 @@ const Sharp = function (input, options) { pngQuality: 100, pngBitdepth: 8, pngDither: 1, + jp2Quality: 80, + jp2TileHeight: 512, + jp2TileWidth: 512, + jp2Lossless: false, + jp2ChromaSubsampling: '4:4:4', webpQuality: 80, webpAlphaQuality: 100, webpLossless: false, diff --git a/lib/output.js b/lib/output.js index 80277a3d..46a37dcf 100644 --- a/lib/output.js +++ b/lib/output.js @@ -13,10 +13,15 @@ const formats = new Map([ ['raw', 'raw'], ['tiff', 'tiff'], ['webp', 'webp'], - ['gif', 'gif'] + ['gif', 'gif'], + ['jp2', 'jp2'], + ['jpx', 'jp2'], + ['j2k', 'jp2'], + ['j2c', 'jp2'] ]); const errMagickSave = new Error('GIF output requires libvips with support for ImageMagick'); +const errJp2Save = new Error('JP2 output requires libvips with support for OpenJPEG'); /** * Write output image data to a file. @@ -511,6 +516,82 @@ function gif (options) { return this._updateFormatOut('gif', options); } +/** + * Use these JP2 options for output image. + * + * Requires libvips compiled with support for OpenJPEG. + * The prebuilt binaries do not include this - see + * {@link https://sharp.pixelplumbing.com/install#custom-libvips installing a custom libvips}. + * + * @example + * // Convert any input to lossless JP2 output + * const data = await sharp(input) + * .jp2({ lossless: true }) + * .toBuffer(); + * + * @example + * // Convert any input to very high quality JP2 output + * const data = await sharp(input) + * .jp2({ + * quality: 100, + * chromaSubsampling: '4:4:4' + * }) + * .toBuffer(); + * + * @param {Object} [options] - output options + * @param {number} [options.quality=80] - quality, integer 1-100 + * @param {boolean} [options.lossless=false] - use lossless compression mode + * @param {number} [options.tileWidth=512] - horizontal tile size + * @param {number} [options.tileHeight=512] - vertical tile size + * @param {string} [options.chromaSubsampling='4:4:4'] - set to '4:2:0' to use chroma subsampling + * @returns {Sharp} + * @throws {Error} Invalid options + */ +/* istanbul ignore next */ +function jp2 (options) { + if (!this.constructor.format.jp2k.output.buffer) { + throw errJp2Save; + } + if (is.object(options)) { + if (is.defined(options.quality)) { + if (is.integer(options.quality) && is.inRange(options.quality, 1, 100)) { + this.options.jp2Quality = options.quality; + } else { + throw is.invalidParameterError('quality', 'integer between 1 and 100', options.quality); + } + } + if (is.defined(options.lossless)) { + if (is.bool(options.lossless)) { + this.options.jp2Lossless = options.lossless; + } else { + throw is.invalidParameterError('lossless', 'boolean', options.lossless); + } + } + if (is.defined(options.tileWidth)) { + if (is.integer(options.tileWidth) && is.inRange(options.tileWidth, 1, 32768)) { + this.options.jp2TileWidth = options.tileWidth; + } else { + throw is.invalidParameterError('tileWidth', 'integer between 1 and 32768', options.tileWidth); + } + } + if (is.defined(options.tileHeight)) { + if (is.integer(options.tileHeight) && is.inRange(options.tileHeight, 1, 32768)) { + this.options.jp2TileHeight = options.tileHeight; + } else { + throw is.invalidParameterError('tileHeight', 'integer between 1 and 32768', options.tileHeight); + } + } + if (is.defined(options.chromaSubsampling)) { + if (is.string(options.chromaSubsampling) && is.inArray(options.chromaSubsampling, ['4:2:0', '4:4:4'])) { + this.options.heifChromaSubsampling = options.chromaSubsampling; + } else { + throw is.invalidParameterError('chromaSubsampling', 'one of: 4:2:0, 4:4:4', options.chromaSubsampling); + } + } + } + return this._updateFormatOut('jp2', options); +} + /** * Set animation options if available. * @private @@ -1035,6 +1116,7 @@ module.exports = function (Sharp) { withMetadata, toFormat, jpeg, + jp2, png, webp, tiff, diff --git a/package.json b/package.json index bc8024e5..daaa4c5b 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "Jacob Smith ", "Michael Nutt ", "Brad Parham ", - "Taneli Vatanen " + "Taneli Vatanen ", + "Joris Dugué " ], "scripts": { "install": "(node install/libvips && node install/dll-copy && prebuild-install) || (node install/can-compile && node-gyp rebuild && node install/dll-copy)", @@ -112,6 +113,7 @@ "tiff", "gif", "svg", + "jp2", "dzi", "image", "resize", diff --git a/src/common.cc b/src/common.cc index 17e4634e..a04b2319 100644 --- a/src/common.cc +++ b/src/common.cc @@ -157,6 +157,10 @@ namespace sharp { bool IsGif(std::string const &str) { return EndsWith(str, ".gif") || EndsWith(str, ".GIF"); } + bool IsJp2(std::string const &str) { + return EndsWith(str, ".jp2") || EndsWith(str, ".jpx") || EndsWith(str, ".j2k") || EndsWith(str, ".j2c") + || EndsWith(str, ".JP2") || EndsWith(str, ".JPX") || EndsWith(str, ".J2K") || EndsWith(str, ".J2C"); + } bool IsTiff(std::string const &str) { return EndsWith(str, ".tif") || EndsWith(str, ".tiff") || EndsWith(str, ".TIF") || EndsWith(str, ".TIFF"); } @@ -190,6 +194,7 @@ namespace sharp { case ImageType::WEBP: id = "webp"; break; case ImageType::TIFF: id = "tiff"; break; case ImageType::GIF: id = "gif"; break; + case ImageType::JP2: id = "jp2"; break; case ImageType::SVG: id = "svg"; break; case ImageType::HEIF: id = "heif"; break; case ImageType::PDF: id = "pdf"; break; @@ -226,6 +231,8 @@ namespace sharp { { "VipsForeignLoadGifBuffer", ImageType::GIF }, { "VipsForeignLoadNsgifFile", ImageType::GIF }, { "VipsForeignLoadNsgifBuffer", ImageType::GIF }, + { "VipsForeignLoadJp2kBuffer", ImageType::JP2 }, + { "VipsForeignLoadJp2kFile", ImageType::JP2 }, { "VipsForeignLoadSvgFile", ImageType::SVG }, { "VipsForeignLoadSvgBuffer", ImageType::SVG }, { "VipsForeignLoadHeifFile", ImageType::HEIF }, @@ -287,6 +294,7 @@ namespace sharp { imageType == ImageType::WEBP || imageType == ImageType::MAGICK || imageType == ImageType::GIF || + imageType == ImageType::JP2 || imageType == ImageType::TIFF || imageType == ImageType::HEIF || imageType == ImageType::PDF; diff --git a/src/common.h b/src/common.h index 93d09eb8..9eaa772a 100644 --- a/src/common.h +++ b/src/common.h @@ -116,6 +116,7 @@ namespace sharp { JPEG, PNG, WEBP, + JP2, TIFF, GIF, SVG, @@ -142,6 +143,7 @@ namespace sharp { bool IsJpeg(std::string const &str); bool IsPng(std::string const &str); bool IsWebp(std::string const &str); + bool IsJp2(std::string const &str); bool IsGif(std::string const &str); bool IsTiff(std::string const &str); bool IsHeic(std::string const &str); diff --git a/src/pipeline.cc b/src/pipeline.cc index 2a6fb69d..1ce66998 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -791,6 +791,22 @@ class PipelineWorker : public Napi::AsyncWorker { } else { baton->channels = std::min(baton->channels, 3); } + } else if (baton->formatOut == "jp2" || (baton->formatOut == "input" + && inputImageType == sharp::ImageType::JP2)) { + // Write JP2 to Buffer + sharp::AssertImageTypeDimensions(image, sharp::ImageType::JP2); + VipsArea *area = reinterpret_cast(image.jp2ksave_buffer(VImage::option() + ->set("Q", baton->jp2Quality) + ->set("lossless", baton->jp2Lossless) + ->set("subsample_mode", baton->jp2ChromaSubsampling == "4:4:4" + ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) + ->set("tile_height", baton->jp2TileHeight) + ->set("tile_width", baton->jp2TileWidth))); + baton->bufferOut = static_cast(area->data); + baton->bufferOutLength = area->length; + area->free_fn = nullptr; + vips_area_unref(area); + baton->formatOut = "jp2"; } else if (baton->formatOut == "png" || (baton->formatOut == "input" && (inputImageType == sharp::ImageType::PNG || (inputImageType == sharp::ImageType::GIF && !supportsGifOutput) || inputImageType == sharp::ImageType::SVG))) { @@ -922,13 +938,14 @@ class PipelineWorker : public Napi::AsyncWorker { bool const isWebp = sharp::IsWebp(baton->fileOut); bool const isGif = sharp::IsGif(baton->fileOut); bool const isTiff = sharp::IsTiff(baton->fileOut); + bool const isJp2 = sharp::IsJp2(baton->fileOut); bool const isHeif = sharp::IsHeif(baton->fileOut); bool const isDz = sharp::IsDz(baton->fileOut); bool const isDzZip = sharp::IsDzZip(baton->fileOut); bool const isV = sharp::IsV(baton->fileOut); bool const mightMatchInput = baton->formatOut == "input"; bool const willMatchInput = mightMatchInput && - !(isJpeg || isPng || isWebp || isGif || isTiff || isHeif || isDz || isDzZip || isV); + !(isJpeg || isPng || isWebp || isGif || isTiff || isJp2 || isHeif || isDz || isDzZip || isV); if (baton->formatOut == "jpeg" || (mightMatchInput && isJpeg) || (willMatchInput && inputImageType == sharp::ImageType::JPEG)) { @@ -948,6 +965,18 @@ class PipelineWorker : public Napi::AsyncWorker { ->set("optimize_coding", baton->jpegOptimiseCoding)); baton->formatOut = "jpeg"; baton->channels = std::min(baton->channels, 3); + } else if (baton->formatOut == "jp2" || (mightMatchInput && isJp2) || + (willMatchInput && (inputImageType == sharp::ImageType::JP2))) { + // Write JP2 to file + sharp::AssertImageTypeDimensions(image, sharp::ImageType::JP2); + image.jp2ksave(const_cast(baton->fileOut.data()), VImage::option() + ->set("Q", baton->jp2Quality) + ->set("lossless", baton->jp2Lossless) + ->set("subsample_mode", baton->jp2ChromaSubsampling == "4:4:4" + ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) + ->set("tile_height", baton->jp2TileHeight) + ->set("tile_width", baton->jp2TileWidth)); + baton->formatOut = "jp2"; } else if (baton->formatOut == "png" || (mightMatchInput && isPng) || (willMatchInput && (inputImageType == sharp::ImageType::PNG || (inputImageType == sharp::ImageType::GIF && !supportsGifOutput) || inputImageType == sharp::ImageType::SVG))) { @@ -1438,6 +1467,11 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->pngQuality = sharp::AttrAsUint32(options, "pngQuality"); baton->pngBitdepth = sharp::AttrAsUint32(options, "pngBitdepth"); baton->pngDither = sharp::AttrAsDouble(options, "pngDither"); + baton->jp2Quality = sharp::AttrAsUint32(options, "jp2Quality"); + baton->jp2Lossless = sharp::AttrAsBool(options, "jp2Lossless"); + baton->jp2TileHeight = sharp::AttrAsUint32(options, "jp2TileHeight"); + baton->jp2TileWidth = sharp::AttrAsUint32(options, "jp2TileWidth"); + baton->jp2ChromaSubsampling = sharp::AttrAsStr(options, "jp2ChromaSubsampling"); baton->webpQuality = sharp::AttrAsUint32(options, "webpQuality"); baton->webpAlphaQuality = sharp::AttrAsUint32(options, "webpAlphaQuality"); baton->webpLossless = sharp::AttrAsBool(options, "webpLossless"); diff --git a/src/pipeline.h b/src/pipeline.h index ec9bf8dc..2c2f1ee9 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -149,6 +149,11 @@ struct PipelineBaton { int pngQuality; int pngBitdepth; double pngDither; + int jp2Quality; + bool jp2Lossless; + int jp2TileHeight; + int jp2TileWidth; + std::string jp2ChromaSubsampling; int webpQuality; int webpAlphaQuality; bool webpNearLossless; @@ -280,6 +285,11 @@ struct PipelineBaton { pngQuality(100), pngBitdepth(8), pngDither(1.0), + jp2Quality(80), + jp2Lossless(false), + jp2TileHeight(512), + jp2TileWidth(512), + jp2ChromaSubsampling("4:4:4"), webpQuality(80), webpAlphaQuality(100), webpNearLossless(false), diff --git a/src/utilities.cc b/src/utilities.cc index ed36d76d..37c1cefd 100644 --- a/src/utilities.cc +++ b/src/utilities.cc @@ -115,7 +115,7 @@ Napi::Value format(const Napi::CallbackInfo& info) { Napi::Object format = Napi::Object::New(env); for (std::string const f : { "jpeg", "png", "webp", "tiff", "magick", "openslide", "dz", - "ppm", "fits", "gif", "svg", "heif", "pdf", "vips" + "ppm", "fits", "gif", "svg", "heif", "pdf", "vips", "jp2k" }) { // Input Napi::Boolean hasInputFile = diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 9e8b019a..e486934a 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -105,6 +105,8 @@ module.exports = { inputTiffUncompressed: getPath('uncompressed_tiff.tiff'), // https://code.google.com/archive/p/imagetestsuite/wikis/TIFFTestSuite.wiki file: 0c84d07e1b22b76f24cccc70d8788e4a.tif inputTiff8BitDepth: getPath('8bit_depth.tiff'), inputTifftagPhotoshop: getPath('tifftag-photoshop.tiff'), // https://github.com/lovell/sharp/issues/1600 + + inputJp2: getPath('relax.jp2'), // https://www.fnordware.com/j2k/relax.jp2 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 inputGifAnimated: getPath('rotating-squares.gif'), // CC0 https://loading.io/spinner/blocks/-rotating-squares-preloader-gif diff --git a/test/fixtures/relax.jp2 b/test/fixtures/relax.jp2 new file mode 100644 index 00000000..1823990f Binary files /dev/null and b/test/fixtures/relax.jp2 differ diff --git a/test/unit/jp2.js b/test/unit/jp2.js new file mode 100644 index 00000000..d30987c3 --- /dev/null +++ b/test/unit/jp2.js @@ -0,0 +1,99 @@ +'use strict'; + +const fs = require('fs'); +const assert = require('assert'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +describe('JP2 output', () => { + if (!sharp.format.jp2k.input.buffer) { + it('JP2 output should fail due to missing OpenJPEG', () => { + assert.rejects(() => + sharp(fixtures.inputJpg) + .jp2() + .toBuffer(), + /JP2 output requires libvips with support for OpenJPEG/ + ); + }); + + it('JP2 file output should fail due to missing OpenJPEG', () => { + assert.rejects(async () => await sharp().toFile('test.jp2'), + /JP2 output requires libvips with support for OpenJPEG/ + ); + }); + } else { + it('JP2 Buffer to PNG Buffer', () => { + sharp(fs.readFileSync(fixtures.inputJp2)) + .resize(8, 15) + .png() + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => { + assert.strictEqual(true, data.length > 0); + assert.strictEqual(data.length, info.size); + assert.strictEqual('png', info.format); + assert.strictEqual(8, info.width); + assert.strictEqual(15, info.height); + assert.strictEqual(4, info.channels); + }); + }); + + it('JP2 quality', function (done) { + sharp(fixtures.inputJp2) + .resize(320, 240) + .jp2({ quality: 70 }) + .toBuffer(function (err, buffer70) { + if (err) throw err; + sharp(fixtures.inputJp2) + .resize(320, 240) + .toBuffer(function (err, buffer80) { + if (err) throw err; + sharp(fixtures.inputJp2) + .resize(320, 240) + .jp2({ quality: 90 }) + .toBuffer(function (err, buffer90) { + if (err) throw err; + assert(buffer70.length < buffer80.length); + assert(buffer80.length < buffer90.length); + done(); + }); + }); + }); + }); + + it('Without chroma subsampling generates larger file', function (done) { + // First generate with chroma subsampling (default) + sharp(fixtures.inputJp2) + .resize(320, 240) + .jp2({ chromaSubsampling: '4:2:0' }) + .toBuffer(function (err, withChromaSubsamplingData, withChromaSubsamplingInfo) { + if (err) throw err; + assert.strictEqual(true, withChromaSubsamplingData.length > 0); + assert.strictEqual(withChromaSubsamplingData.length, withChromaSubsamplingInfo.size); + assert.strictEqual('jp2', withChromaSubsamplingInfo.format); + assert.strictEqual(320, withChromaSubsamplingInfo.width); + assert.strictEqual(240, withChromaSubsamplingInfo.height); + // Then generate without + sharp(fixtures.inputJp2) + .resize(320, 240) + .jp2({ chromaSubsampling: '4:4:4' }) + .toBuffer(function (err, withoutChromaSubsamplingData, withoutChromaSubsamplingInfo) { + if (err) throw err; + assert.strictEqual(true, withoutChromaSubsamplingData.length > 0); + assert.strictEqual(withoutChromaSubsamplingData.length, withoutChromaSubsamplingInfo.size); + assert.strictEqual('jp2', withoutChromaSubsamplingInfo.format); + assert.strictEqual(320, withoutChromaSubsamplingInfo.width); + assert.strictEqual(240, withoutChromaSubsamplingInfo.height); + assert.strictEqual(true, withChromaSubsamplingData.length <= withoutChromaSubsamplingData.length); + done(); + }); + }); + }); + + it('Invalid JP2 chromaSubsampling value throws error', function () { + assert.throws(function () { + sharp().jpeg({ chromaSubsampling: '4:2:2' }); + }); + }); + } +});