diff --git a/.gitignore b/.gitignore index c113379..0161442 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules test/img/ sample/img/ sample/.cache/ -package-lock.json \ No newline at end of file +package-lock.json +yarn.lock \ No newline at end of file diff --git a/README.md b/README.md index c90dbd0..87d72f7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ Defaults values are shown: // widths: [200] // output 200px maxwidth // widths: [200, null] // output 200px and original width + // Array of crops + // Optional: use falsy value to skip cropping + crops: null, // ["1600x900", "160x90"] or [ { width: 1600, height: 900 }, { width: 160, height: 90 } ] + // output image formats formats: ["webp", "jpeg"], // also supported by sharp: "png", "raw", "tiff" diff --git a/img.js b/img.js index 2dbcc1f..a957bb1 100644 --- a/img.js +++ b/img.js @@ -13,19 +13,20 @@ const debug = require("debug")("EleventyImg"); const CacheAsset = require("@11ty/eleventy-cache-assets"); const globalOptions = { - src: null, - widths: [null], - formats: ["webp", "jpeg"], // "png" - concurrency: 10, - urlPath: "/img/", - outputDir: "img/", - cacheDuration: "1d", // deprecated, use cacheOptions.duration - cacheOptions: { - // duration: "1d", - // directory: ".cache", - // removeUrlQueryParams: false, - // fetchOptions: {}, - }, + src: null, + widths: [null], + crops: null, + formats: ["webp", "jpeg"], // "png" + concurrency: 10, + urlPath: "/img/", + outputDir: "img/", + cacheDuration: "1d", // deprecated, use cacheOptions.duration + cacheOptions: { + // duration: "1d", + // directory: ".cache", + // removeUrlQueryParams: false, + // fetchOptions: {}, + }, }; const MIME_TYPES = { @@ -90,75 +91,133 @@ function transformRawFiles(files = []) { // src should be a file path to an image or a buffer async function resizeImage(src, options = {}) { - let sharpImage = sharp(src, { - failOnError: false, - // TODO how to handle higher resolution source images - // density: 72 - }); - - if(typeof src !== "string") { - if(options.sourceUrl) { - src = options.sourceUrl; - } else { - throw new Error(`Expected options.sourceUrl in resizeImage when using Buffer as input.`); - } - } - - // Must find the image format from the metadata - // File extensions lie or may not be present in the src url! - let metadata = await sharpImage.metadata(); - let outputFilePromises = []; - - let formats = getFormatsArray(options.formats); - for(let format of formats) { - let hasAtLeastOneValidMaxWidth = false; - for(let width of options.widths) { - let hasWidth = !!width; - // Set format - let imageFormat = sharpImage.clone(); - if(metadata.format !== format) { - imageFormat.toFormat(format); - } - - // skip this width because it’s larger than the original and we already - // have at least one output image size that works - if(hasAtLeastOneValidMaxWidth && (!width || width > metadata.width)) { - continue; - } - - // Resize the image - if(!width) { - hasAtLeastOneValidMaxWidth = true; - } else { - if(width >= metadata.width) { - // don’t reassign width if it’s falsy - width = null; - hasWidth = false; - hasAtLeastOneValidMaxWidth = true; - } else { - imageFormat.resize({ - width: width, - withoutEnlargement: true - }); - } - } - - - let outputFilename = getFilename(src, width, format); - let outputPath = path.join(options.outputDir, outputFilename); - outputFilePromises.push(imageFormat.toFile(outputPath).then(data => { - let stats = getStats(src, format, options.urlPath, data.width, data.height, hasWidth); - stats.outputPath = outputPath; - stats.size = data.size; - - return stats; - })); - - debug( "Writing %o", outputPath ); - } - } + let sharpImage = sharp(src, { + failOnError: false, + // TODO how to handle higher resolution source images + // density: 72 + }); + + if(typeof src !== "string") { + if(options.sourceUrl) { + src = options.sourceUrl; + } else { + throw new Error(`Expected options.sourceUrl in resizeImage when using Buffer as input.`); + } + } + + // Must find the image format from the metadata + // File extensions lie or may not be present in the src url! + let metadata = await sharpImage.metadata(); + let outputFilePromises = []; + + let formats = getFormatsArray(options.formats); + for(let format of formats) { + let hasAtLeastOneValidMaxWidth = false; + if (options.crops) { + let crops = _normalizeCrop(options.crops); + if (crops.length == 0) { + throw new Error(`Expected options.crops should be ["1600x900", "160x90"] or ${JSON.stringify([{ width: 1600, height: 900 }, { width: 160, height: 90 }], null, 2)}`); + } + for (let [width, height] of crops) { + let hasWidth = !!width; + // Set format + let imageFormat = sharpImage.clone(); + if(metadata.format !== format) { + imageFormat.toFormat(format); + } + + if(width > metadata.width || height > metadata.height) { + continue; + } + + imageFormat.resize({ + width: width, + height: height, + withoutEnlargement: true + }); + + let outputFilename = getFilename(src, width + 'x' + height, format); + let outputPath = path.join(options.outputDir, outputFilename); + outputFilePromises.push(imageFormat.toFile(outputPath).then(data => { + let stats = getStats(src, format, options.urlPath, data.width, data.height, hasWidth); + stats.outputPath = outputPath; + stats.size = data.size; + + return stats; + })); + + debug( "Writing %o", outputPath ); + } + } else { + for(let width of options.widths) { + let hasWidth = !!width; + // Set format + let imageFormat = sharpImage.clone(); + if(metadata.format !== format) { + imageFormat.toFormat(format); + } + + // skip this width because it’s larger than the original and we already + // have at least one output image size that works + if(hasAtLeastOneValidMaxWidth && (!width || width > metadata.width)) { + continue; + } + + // Resize the image + if(!width) { + hasAtLeastOneValidMaxWidth = true; + } else { + if(width >= metadata.width) { + // don’t reassign width if it’s falsy + width = null; + hasWidth = false; + hasAtLeastOneValidMaxWidth = true; + } else { + imageFormat.resize({ + width: width, + withoutEnlargement: true + }); + } + } + + + let outputFilename = getFilename(src, width, format); + let outputPath = path.join(options.outputDir, outputFilename); + outputFilePromises.push(imageFormat.toFile(outputPath).then(data => { + let stats = getStats(src, format, options.urlPath, data.width, data.height, hasWidth); + stats.outputPath = outputPath; + stats.size = data.size; + + return stats; + })); + + debug( "Writing %o", outputPath ); + } + } + } + + return Promise.all(outputFilePromises).then(files => transformRawFiles(files)); +} - return Promise.all(outputFilePromises).then(files => transformRawFiles(files)); +function _normalizeCrop(options) { + if (options == null) return null; + let filteredOptions = options.map(function(config) { + if (typeof(config) == 'string') { + let val = config.split('x') + if (config.split('x').length != 2) { + return false; + } + return [parseInt(val[0]), parseInt(val[1])] + } else if (typeof(config) == 'object') { + let width = config.hasOwnProperty('width'); + let height = config.hasOwnProperty('height'); + if (width && height) { + return [parseInt(config.width), parseInt(config.height)]; + } + return false; + } + }) + return filteredOptions.filter(Boolean); } function isFullUrl(url) { @@ -229,40 +288,50 @@ Object.defineProperty(module.exports, "concurrency", { */ function _statsSync(src, originalWidth, originalHeight, opts) { - let options = Object.assign({}, globalOptions, opts); - - let results = []; - let formats = getFormatsArray(options.formats); - - for(let format of formats) { - let hasAtLeastOneValidMaxWidth = false; - for(let width of options.widths) { - let hasWidth = !!width; - let height; - - if(hasAtLeastOneValidMaxWidth && (!width || width > originalWidth)) { - continue; - } - - if(!width) { - width = originalWidth; - height = originalHeight; - hasAtLeastOneValidMaxWidth = true; - } else { - if(width >= originalWidth) { - width = originalWidth; - hasWidth = false; - hasAtLeastOneValidMaxWidth = true; - } - height = Math.floor(width * originalHeight / originalWidth); - } - - - results.push(getStats(src, format, options.urlPath, width, height, hasWidth)); - } - } - - return transformRawFiles(results); + let options = Object.assign({}, globalOptions, opts); + + let results = []; + let formats = getFormatsArray(options.formats); + + for(let format of formats) { + let hasAtLeastOneValidMaxWidth = false; + if (options.crops) { + let crops = _normalizeCrop(options.crops); + for (let [width, height] of crops) { + let hasWidth = !!width + if(width > originalWidth || height > originalWidth) { + continue; + } + results.push(getStats(src, format, options.urlPath, width, height, hasWidth)); + } + } else { + for(let width of options.widths) { + let hasWidth = !!width; + let height; + + if(hasAtLeastOneValidMaxWidth && (!width || width > originalWidth)) { + continue; + } + + if(!width) { + width = originalWidth; + height = originalHeight; + hasAtLeastOneValidMaxWidth = true; + } else { + if(width >= originalWidth) { + width = originalWidth; + hasWidth = false; + hasAtLeastOneValidMaxWidth = true; + } + height = Math.floor(width * originalHeight / originalWidth); + } + + results.push(getStats(src, format, options.urlPath, width, height, hasWidth)); + } + } + } + + return transformRawFiles(results); }; function statsSync(src, opts) { @@ -275,4 +344,4 @@ function statsByDimensionsSync(src, width, height, opts) { } module.exports.statsSync = statsSync; -module.exports.statsByDimensionsSync = statsByDimensionsSync; +module.exports.statsByDimensionsSync = statsByDimensionsSync; \ No newline at end of file diff --git a/test/test.js b/test/test.js index e6cfc88..17875ba 100644 --- a/test/test.js +++ b/test/test.js @@ -168,6 +168,100 @@ test("Use exact same width as original (statsSync)", t => { t.is(stats.jpeg[0].width, 1280); }); +test("Use crop feature case 1", async t => { + let stats = await eleventyImage("./test/bio-2017.jpg", { + crops: ["160x90"], + formats: ["jpeg"], + outputDir: "./test/img/" + }); + t.is(stats.jpeg.length, 1); +}); + +test("Use crop feature case 2 (ignore image larger than original)", async t => { + let stats = await eleventyImage("./test/bio-2017.jpg", { + crops: ["1600x900", "160x90"], + formats: ["jpeg"], + outputDir: "./test/img/" + }); + t.is(stats.jpeg.length, 1); + t.is(stats.jpeg[0].outputPath, "test/img/97854483-160x90.jpeg"); // no width in filename + t.is(stats.jpeg[0].width, 160); + t.is(stats.jpeg[0].height, 90); +}); + +test("Use crop feature case 3 (ignore image larger than original)", async t => { + let stats = await eleventyImage("./test/bio-2017.jpg", { + crops: [{ + width: 1600, + height: 900 + }, { + width: 160, + height: 90 + }], + formats: ["jpeg"], + outputDir: "./test/img/" + }); + t.is(stats.jpeg.length, 1); + t.is(stats.jpeg[0].outputPath, "test/img/97854483-160x90.jpeg"); // no width in filename + t.is(stats.jpeg[0].width, 160); + t.is(stats.jpeg[0].height, 90); +}); + +test("Use crop feature case 4", async t => { + let stats = await eleventyImage("./test/bio-2017.jpg", { + crops: [{ + width: 800, + height: 600 + }, { + width: 160, + height: 90 + }], + formats: ["jpeg"], + outputDir: "./test/img/" + }); + t.is(stats.jpeg.length, 2); + t.is(stats.jpeg[0].outputPath, "test/img/97854483-160x90.jpeg"); // no width in filename + t.is(stats.jpeg[0].width, 160); + t.is(stats.jpeg[0].height, 90); + t.is(stats.jpeg[1].outputPath, "test/img/97854483-800x600.jpeg"); // no width in filename + t.is(stats.jpeg[1].width, 800); + t.is(stats.jpeg[1].height, 600); +}); + +test("Sync with crop feature case 1", t => { + let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { + crops: [{ + width: 800, + height: 600 + }, { + width: 160, + height: 90 + }] + }); + t.is(stats.webp.length, 2); + t.is(stats.webp[0].width, 160); + t.is(stats.webp[0].height, 90); + t.is(stats.webp[1].width, 800); + t.is(stats.webp[1].height, 600); + t.is(stats.jpeg.length, 2); + t.is(stats.jpeg[0].width, 160); + t.is(stats.jpeg[0].height, 90); + t.is(stats.jpeg[1].width, 800); + t.is(stats.jpeg[1].height, 600); +}); + +test("Sync with crop feature case 2 (ignore image larger than original)", t => { + let stats = eleventyImage.statsSync("./test/bio-2017.jpg", { + crops: ["1600x900", "160x90"] + }); + t.is(stats.webp.length, 1); + t.is(stats.webp[0].width, 160); + t.is(stats.webp[0].height, 90); + t.is(stats.jpeg.length, 1); + t.is(stats.jpeg[0].width, 160); + t.is(stats.jpeg[0].height, 90); +}); + test("Unavatar test", t => { let stats = eleventyImage.statsByDimensionsSync("https://unavatar.now.sh/twitter/zachleat?fallback=false", 400, 400, { widths: [75] @@ -180,3 +274,16 @@ test("Unavatar test", t => { t.is(stats.jpeg[0].width, 75); t.is(stats.jpeg[0].height, 75); }); + +test("Unavatar crop test", t => { + let stats = eleventyImage.statsByDimensionsSync("https://unavatar.now.sh/twitter/zachleat?fallback=false", 400, 400, { + crops: ["300x400"] + }); + + t.is(stats.webp.length, 1); + t.is(stats.webp[0].width, 300); + t.is(stats.webp[0].height, 400); + t.is(stats.jpeg.length, 1); + t.is(stats.jpeg[0].width, 300); + t.is(stats.jpeg[0].height, 400); +});