diff --git a/docs/api-output.md b/docs/api-output.md index 353236f4f..5ff48754b 100644 --- a/docs/api-output.md +++ b/docs/api-output.md @@ -279,8 +279,9 @@ Warning: multiple sharp instances concurrently producing tile output can expose - `tile.overlap` **[Number][8]** tile overlap in pixels, a value between 0 and 8192. (optional, default `0`) - `tile.angle` **[Number][8]** tile angle of rotation, must be a multiple of 90. (optional, default `0`) - `tile.container` **[String][1]** tile container, with value `fs` (filesystem) or `zip` (compressed file). (optional, default `'fs'`) - - `tile.layout` **[String][1]** filesystem layout, possible values are `dz`, `zoomify` or `google`. (optional, default `'dz'`) - + - `tile.layout` **[String][1]** filesystem layout, possible values are `dz`, `zoomify` or `google`. (optional, default `'dz'`) + - `tile.depth` **[String][1]** pyramid depth, possible values are `onepixel`, `onetile` or `one`. (optional, default - libvips selects one based on layout) + ### Examples ```javascript diff --git a/lib/output.js b/lib/output.js index 5e4a1c7b2..5585b087e 100644 --- a/lib/output.js +++ b/lib/output.js @@ -423,6 +423,7 @@ function toFormat (format, options) { * @param {Number} [tile.size=256] tile size in pixels, a value between 1 and 8192. * @param {Number} [tile.overlap=0] tile overlap in pixels, a value between 0 and 8192. * @param {Number} [tile.angle=0] tile angle of rotation, must be a multiple of 90. + * @param {String} [tile.depth] depth to shrink tiles, with value 'onepixel', 'onetile' or 'one', default based on layout * @param {String} [tile.container='fs'] tile container, with value `fs` (filesystem) or `zip` (compressed file). * @param {String} [tile.layout='dz'] filesystem layout, possible values are `dz`, `zoomify` or `google`. * @returns {Sharp} @@ -474,6 +475,15 @@ function tile (tile) { throw new Error('Unsupported angle: angle must be a positive/negative multiple of 90 ' + tile.angle); } } + + // Depth of tiles + if (is.defined(tile.depth)) { + if (is.string(tile.depth) && is.inArray(tile.depth, ['onepixel', 'onetile', 'one'])) { + this.options.tileDepth = tile.depth; + } else { + throw new Error("Invalid tile depth '" + tile.depth + "', should be one of 'onepixel', 'onetile' or 'one'"); + } + } } // Format if (is.inArray(this.options.formatOut, ['jpeg', 'png', 'webp'])) { diff --git a/src/pipeline.cc b/src/pipeline.cc index 5cad5f013..dedb376fd 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -946,14 +946,22 @@ class PipelineWorker : public Nan::AsyncWorker { suffix = AssembleSuffixString(extname, options); } // Write DZ to file - image.dzsave(const_cast(baton->fileOut.data()), VImage::option() - ->set("strip", !baton->withMetadata) - ->set("tile_size", baton->tileSize) - ->set("overlap", baton->tileOverlap) - ->set("container", baton->tileContainer) - ->set("layout", baton->tileLayout) - ->set("suffix", const_cast(suffix.data())) - ->set("angle", CalculateAngleRotation(baton->tileAngle))); + vips::VOption *options = VImage::option() + ->set("strip", !baton->withMetadata) + ->set("tile_size", baton->tileSize) + ->set("overlap", baton->tileOverlap) + ->set("container", baton->tileContainer) + ->set("layout", baton->tileLayout) + ->set("suffix", const_cast(suffix.data())) + ->set("angle", CalculateAngleRotation(baton->tileAngle)); + + // libvips chooses a default depth based on layout. Instead of replicating that logic here by + // not passing anything - libvips will handle choice + if (baton->tileDepth < VIPS_FOREIGN_DZ_DEPTH_LAST) { + options->set("depth", baton->tileDepth); + } + + image.dzsave(const_cast(baton->fileOut.data()), options); baton->formatOut = "dz"; } else if (baton->formatOut == "v" || (mightMatchInput && isV) || (willMatchInput && inputImageType == ImageType::VIPS)) { @@ -1321,6 +1329,17 @@ NAN_METHOD(pipeline) { baton->tileLayout = VIPS_FOREIGN_DZ_LAYOUT_DZ; } baton->tileFormat = AttrAsStr(options, "tileFormat"); + std::string tileDepth = AttrAsStr(options, "tileDepth"); + if (tileDepth == "onetile") { + baton->tileDepth = VIPS_FOREIGN_DZ_DEPTH_ONETILE; + } else if (tileDepth == "one") { + baton->tileDepth = VIPS_FOREIGN_DZ_DEPTH_ONE; + } else if (tileDepth == "onepixel") { + baton->tileDepth = VIPS_FOREIGN_DZ_DEPTH_ONEPIXEL; + } else { + // signal that we do not want to pass any value to dzSave + baton->tileDepth = VIPS_FOREIGN_DZ_DEPTH_LAST; + } // Force random access for certain operations if (baton->accessMethod == VIPS_ACCESS_SEQUENTIAL && ( baton->trimTolerance != 0 || baton->normalise || diff --git a/src/pipeline.h b/src/pipeline.h index 9ed89fd24..6cfdf2c50 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -139,6 +139,7 @@ struct PipelineBaton { VipsForeignDzLayout tileLayout; std::string tileFormat; int tileAngle; + VipsForeignDzDepth tileDepth; PipelineBaton(): input(nullptr), @@ -220,7 +221,8 @@ struct PipelineBaton { tileOverlap(0), tileContainer(VIPS_FOREIGN_DZ_CONTAINER_FS), tileLayout(VIPS_FOREIGN_DZ_LAYOUT_DZ), - tileAngle(0){ + tileAngle(0), + tileDepth(VIPS_FOREIGN_DZ_DEPTH_LAST){ background[0] = 0.0; background[1] = 0.0; background[2] = 0.0; diff --git a/test/unit/tile.js b/test/unit/tile.js index 1bbcf9539..ce8685c61 100644 --- a/test/unit/tile.js +++ b/test/unit/tile.js @@ -46,6 +46,51 @@ const assertDeepZoomTiles = function (directory, expectedSize, expectedLevels, d }, done); }; +const assertZoomifyTiles = function (directory, expectedTileSize, expectedLevels, done) { + fs.stat(path.join(directory, 'ImageProperties.xml'), function (err, stat) { + if (err) throw err; + assert.ok(stat.isFile()); + assert.ok(stat.size > 0); + + let maxTileLevel = -1; + fs.readdirSync(path.join(directory, 'TileGroup0')).forEach(function (tile) { + // Verify tile file name + assert.ok(/^[0-9]+-[0-9]+-[0-9]+\.jpg$/.test(tile)); + let level = parseInt(tile.split('-')[0]); + maxTileLevel = Math.max(maxTileLevel, level); + }); + + assert.strictEqual(maxTileLevel + 1, expectedLevels); // add one to account for zero level tile + + done(); + }); +}; + +const assertGoogleTiles = function (directory, expectedTileSize, expectedLevels, done) { + const levels = fs.readdirSync(directory); + assert.strictEqual(expectedLevels, levels.length - 1); // subtract one to account for default blank tile + + fs.stat(path.join(directory, 'blank.png'), function (err, stat) { + if (err) throw err; + assert.ok(stat.isFile()); + assert.ok(stat.size > 0); + + // Basic check to confirm lowest and highest level tiles exist + fs.stat(path.join(directory, '0', '0', '0.jpg'), function (err, stat) { + if (err) throw err; + assert.strictEqual(true, stat.isFile()); + assert.strictEqual(true, stat.size > 0); + + fs.stat(path.join(directory, (expectedLevels - 1).toString(), '0', '0.jpg'), function (err, stat) { + if (err) throw err; + assert.strictEqual(true, stat.isFile()); + assert.strictEqual(true, stat.size > 0); + done(); + }); + }); + }); +}; + describe('Tile', function () { it('Valid size values pass', function () { [1, 8192].forEach(function (size) { @@ -144,6 +189,26 @@ describe('Tile', function () { }); }); + it('Valid depths pass', function () { + ['onepixel', 'onetile', 'one'].forEach(function (depth) { + assert.doesNotThrow(function (depth) { + sharp().tile({ + depth: depth + }); + }); + }); + }); + + it('Invalid depths fail', function () { + ['depth', 1].forEach(function (depth) { + assert.throws(function () { + sharp().tile({ + depth: depth + }); + }); + }); + }); + it('Prevent larger overlap than default size', function () { assert.throws(function () { sharp().tile({ @@ -251,6 +316,54 @@ describe('Tile', function () { }); }); + it('Deep Zoom layout with depth of one', function (done) { + const directory = fixtures.path('output.512_depth_one.dzi_files'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 512, + depth: 'one' + }) + .toFile(fixtures.path('output.512_depth_one.dzi'), function (err, info) { + if (err) throw err; + // Verify only one depth generated + assertDeepZoomTiles(directory, 512, 1, done); + }); + }); + }); + + it('Deep Zoom layout with depth of onepixel', function (done) { + const directory = fixtures.path('output.512_depth_onepixel.dzi_files'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 512, + depth: 'onepixel' + }) + .toFile(fixtures.path('output.512_depth_onepixel.dzi'), function (err, info) { + if (err) throw err; + // Verify only one depth generated + assertDeepZoomTiles(directory, 512, 13, done); + }); + }); + }); + + it('Deep Zoom layout with depth of onetile', function (done) { + const directory = fixtures.path('output.256_depth_onetile.dzi_files'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 256, + depth: 'onetile' + }) + .toFile(fixtures.path('output.256_depth_onetile.dzi'), function (err, info) { + if (err) throw err; + // Verify only one depth generated + assertDeepZoomTiles(directory, 256, 5, done); + }); + }); + }); + it('Zoomify layout', function (done) { const directory = fixtures.path('output.zoomify.dzi'); rimraf(directory, function () { @@ -275,6 +388,69 @@ describe('Tile', function () { }); }); + it('Zoomify layout with depth one', function (done) { + const directory = fixtures.path('output.zoomify.depth_one.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 256, + layout: 'zoomify', + depth: 'one' + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + assertZoomifyTiles(directory, 256, 1, done); + }); + }); + }); + + it('Zoomify layout with depth onetile', function (done) { + const directory = fixtures.path('output.zoomify.depth_onetile.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 256, + layout: 'zoomify', + depth: 'onetile' + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + assertZoomifyTiles(directory, 256, 5, done); + }); + }); + }); + + it('Zoomify layout with depth onepixel', function (done) { + const directory = fixtures.path('output.zoomify.depth_onepixel.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + size: 256, + layout: 'zoomify', + depth: 'onepixel' + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + assertZoomifyTiles(directory, 256, 13, done); + }); + }); + }); + it('Google layout', function (done) { const directory = fixtures.path('output.google.dzi'); rimraf(directory, function () { @@ -410,6 +586,72 @@ describe('Tile', function () { }); }); + it('Google layout with depth one', function (done) { + const directory = fixtures.path('output.google_depth_one.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + layout: 'google', + depth: 'one', + size: 256 + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + + assertGoogleTiles(directory, 256, 1, done); + }); + }); + }); + + it('Google layout with depth onepixel', function (done) { + const directory = fixtures.path('output.google_depth_onepixel.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + layout: 'google', + depth: 'onepixel', + size: 256 + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + + assertGoogleTiles(directory, 256, 13, done); + }); + }); + }); + + it('Google layout with depth onetile', function (done) { + const directory = fixtures.path('output.google_depth_onetile.dzi'); + rimraf(directory, function () { + sharp(fixtures.inputJpg) + .tile({ + layout: 'google', + depth: 'onetile', + size: 256 + }) + .toFile(directory, function (err, info) { + if (err) throw err; + assert.strictEqual('dz', info.format); + assert.strictEqual(2725, info.width); + assert.strictEqual(2225, info.height); + assert.strictEqual(3, info.channels); + assert.strictEqual('number', typeof info.size); + + assertGoogleTiles(directory, 256, 5, done); + }); + }); + }); + it('Write to ZIP container using file extension', function (done) { const container = fixtures.path('output.dz.container.zip'); const extractTo = fixtures.path('output.dz.container');