From a83f4919bec28804fe3e8a03a453a30d3537aff6 Mon Sep 17 00:00:00 2001 From: OlenDavis Date: Fri, 26 Jul 2024 07:58:31 -0700 Subject: [PATCH] feat: add unit for percentage resize with sharp --- README.md | 43 +++++ src/index.js | 1 + src/loader.js | 18 +- src/utils.js | 30 +++- ...ator-and-minimizer-percent-resize-query.js | 15 ++ test/resize-query.test.js | 158 ++++++++++++++++++ types/index.d.ts | 1 + types/utils.d.ts | 1 + 8 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/generator-and-minimizer-percent-resize-query.js diff --git a/README.md b/README.md index bf25acc..b0a78b6 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,10 @@ The plugin supports the following query parameters: - `height`/`h` - allows you to set the image height - `as` - to specify the [preset](#preset) option + **Only supported for `sharp` currently:** + +- `unit`/`u` - can be `px` or `percent` and allows you to resize by a percentage of the image's size. + Examples: ```js @@ -483,6 +487,8 @@ const myImage3 = new URL("image.png?w=150", import.meta.url); const myImage4 = new URL("image.png?as=webp&w=150&h=120", import.meta.url); // You can use `auto` to reset `width` or `height` from the `preset` option const myImage5 = new URL("image.png?as=webp&w=150&h=auto", import.meta.url); +// You can use `unit` to get the non-retina resize of images that are retina sized +const myImage1 = new URL("image.png?width=50&unit=percent", import.meta.url); ``` ```css @@ -1494,6 +1500,43 @@ module.exports = { You can find more information [here](https://github.com/GoogleChromeLabs/squoosh/tree/dev/libsquoosh). +For only `sharp` currently, you can even generate the non-retina resizes of images: + +**webpack.config.js** + +```js +const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin"); + +module.exports = { + optimization: { + minimizer: [ + "...", + new ImageMinimizerPlugin({ + generator: [ + { + // You can apply generator using `?as=webp-1x`, you can use any name and provide more options + preset: "webp-1x", + implementation: ImageMinimizerPlugin.sharpGenerate, + options: { + resize: { + enabled: true, + width: 50, + unit: "percent", + }, + encodeOptions: { + webp: { + quality: 90, + }, + }, + }, + }, + ], + }), + ], + }, +}; +``` + #### Generator example for user defined implementation You can use your own generator implementation. diff --git a/src/index.js b/src/index.js index b0e36ca..f5b75bc 100644 --- a/src/index.js +++ b/src/index.js @@ -86,6 +86,7 @@ const { * @typedef {Object} ResizeOptions * @property {number} [width] * @property {number} [height] + * @property {"px" | "percent"} [unit] * @property {boolean} [enabled] */ diff --git a/src/loader.js b/src/loader.js index 7e666c8..945fc7f 100644 --- a/src/loader.js +++ b/src/loader.js @@ -46,9 +46,10 @@ function changeResource(loaderContext, isAbsolute, output, query) { * @param {Minimizer[]} transformers * @param {string | null} widthQuery * @param {string | null} heightQuery + * @param {string | null} unitQuery * @return {Minimizer[]} */ -function processSizeQuery(transformers, widthQuery, heightQuery) { +function processSizeQuery(transformers, widthQuery, heightQuery, unitQuery) { return transformers.map((transformer) => { const minimizer = { ...transformer }; @@ -80,6 +81,10 @@ function processSizeQuery(transformers, widthQuery, heightQuery) { } } + if (unitQuery === "px" || unitQuery === "percent") { + minimizerOptions.resize.unit = unitQuery; + } + return minimizer; }); } @@ -170,15 +175,22 @@ async function loader(content) { if (parsedQuery) { const widthQuery = parsedQuery.get("width") ?? parsedQuery.get("w"); const heightQuery = parsedQuery.get("height") ?? parsedQuery.get("h"); + const unitQuery = parsedQuery.get("unit") ?? parsedQuery.get("u"); - if (widthQuery || heightQuery) { + if (widthQuery || heightQuery || unitQuery) { if (Array.isArray(transformer)) { - transformer = processSizeQuery(transformer, widthQuery, heightQuery); + transformer = processSizeQuery( + transformer, + widthQuery, + heightQuery, + unitQuery, + ); } else { [transformer] = processSizeQuery( [transformer], widthQuery, heightQuery, + unitQuery, ); } } diff --git a/src/utils.js b/src/utils.js index ca9bca9..ac37c6c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -978,7 +978,7 @@ squooshMinify.teardown = squooshImagePoolTeardown; /** @typedef {import("sharp")} SharpLib */ /** @typedef {import("sharp").Sharp} Sharp */ -/** @typedef {import("sharp").ResizeOptions & { enabled?: boolean }} ResizeOptions */ +/** @typedef {import("sharp").ResizeOptions & { enabled?: boolean; unit?: "px" | "percent" }} ResizeOptions */ /** * @typedef SharpEncodeOptions @@ -1095,12 +1095,38 @@ async function sharpTransform( // ====== resize ====== if (minimizerOptions.resize) { - const { enabled = true, ...params } = minimizerOptions.resize; + const { enabled = true, unit = "px", ...params } = minimizerOptions.resize; if ( enabled && (typeof params.width === "number" || typeof params.height === "number") ) { + if (unit === "percent") { + const originalMetadata = await sharp(original.data).metadata(); + + if ( + typeof params.width === "number" && + originalMetadata.width && + Number.isFinite(originalMetadata.width) && + originalMetadata.width > 0 + ) { + params.width = Math.ceil( + (originalMetadata.width * params.width) / 100, + ); + } + + if ( + typeof params.height === "number" && + originalMetadata.height && + Number.isFinite(originalMetadata.height) && + originalMetadata.height > 0 + ) { + params.height = Math.ceil( + (originalMetadata.height * params.height) / 100, + ); + } + } + imagePipeline.resize(params); } } diff --git a/test/fixtures/generator-and-minimizer-percent-resize-query.js b/test/fixtures/generator-and-minimizer-percent-resize-query.js new file mode 100644 index 0000000..8f2458f --- /dev/null +++ b/test/fixtures/generator-and-minimizer-percent-resize-query.js @@ -0,0 +1,15 @@ +require("./loader-test.png?width=100&unit=percent"); +require("./loader-test.png?w=150&u=percent"); +require("./loader-test.png?height=200&unit=percent"); +require("./loader-test.png?h=250&u=percent"); +require("./loader-test.png?width=300&height=auto&unit=percent"); +require("./loader-test.png?width=auto&height=320&unit=percent"); +require("./loader-test.png?width=350&height=350&unit=percent"); + +require("./loader-test.png?as=webp&width=100&unit=percent"); +require("./loader-test.png?as=webp&w=150&u=percent"); +require("./loader-test.png?as=webp&height=200&unit=percent"); +require("./loader-test.png?as=webp&h=250&u=percent"); +require("./loader-test.png?as=webp&width=300&height=auto&unit=percent"); +require("./loader-test.png?as=webp&width=auto&height=320&unit=percent"); +require("./loader-test.png?as=webp&width=350&height=350&unit=percent"); diff --git a/test/resize-query.test.js b/test/resize-query.test.js index 7265caf..0435dfb 100644 --- a/test/resize-query.test.js +++ b/test/resize-query.test.js @@ -164,6 +164,164 @@ describe("resize query (sharp)", () => { expect(errors).toHaveLength(0); } }); + + it("should generate and resize with percent unit resize options", async () => { + const stats = await runWebpack({ + entry: path.join( + fixturesPath, + "./generator-and-minimizer-resize-query.js", + ), + imageminLoaderOptions: { + minimizer: { + implementation: ImageMinimizerPlugin.sharpMinify, + filename: "[name]-[width]x[height][ext]", + options: { + resize: { + width: 400, + height: 400, + unit: "percent", + }, + encodeOptions: { + png: {}, + }, + }, + }, + generator: [ + { + preset: "webp", + implementation: ImageMinimizerPlugin.sharpGenerate, + filename: "[name]-[width]x[height][ext]", + options: { + resize: { + width: 400, + height: 400, + unit: "percent", + }, + encodeOptions: { + webp: {}, + }, + }, + }, + ], + }, + }); + + const { compilation } = stats; + const { warnings, errors } = compilation; + const sizeOf = promisify(imageSize); + + const assetsList = [ + // asset path, width, height, mime regExp + ["./loader-test-500x2000.png", 500, 2000, /image\/png/i], + ["./loader-test-750x2000.png", 750, 2000, /image\/png/i], + ["./loader-test-2000x1000.png", 2000, 1000, /image\/png/i], + ["./loader-test-2000x1250.png", 2000, 1250, /image\/png/i], + ["./loader-test-1500x1500.png", 1500, 1500, /image\/png/i], + ["./loader-test-1600x1600.png", 1600, 1600, /image\/png/i], + ["./loader-test-1750x1750.png", 1750, 1750, /image\/png/i], + + ["./loader-test-500x2000.webp", 500, 2000, /image\/webp/i], + ["./loader-test-750x2000.webp", 750, 2000, /image\/webp/i], + ["./loader-test-2000x1000.webp", 2000, 1000, /image\/webp/i], + ["./loader-test-2000x1250.webp", 2000, 1250, /image\/webp/i], + ["./loader-test-1500x1500.webp", 1500, 1500, /image\/webp/i], + ["./loader-test-1600x1600.webp", 1600, 1600, /image\/webp/i], + ["./loader-test-1750x1750.webp", 1750, 1750, /image\/webp/i], + ]; + + for (const [assetPath, width, height, mimeRegExp] of assetsList) { + const transformedAsset = path.resolve( + __dirname, + compilation.options.output.path, + assetPath, + ); + + // eslint-disable-next-line no-await-in-loop + const transformedExt = await fileType.fromFile(transformedAsset); + // eslint-disable-next-line no-await-in-loop + const dimensions = await sizeOf(transformedAsset); + + expect(dimensions.width).toBe(width); + expect(dimensions.height).toBe(height); + expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(0); + } + }); + + it("should generate and resize with percent unit query without resize options", async () => { + const stats = await runWebpack({ + entry: path.join( + fixturesPath, + "./generator-and-minimizer-percent-resize-query.js", + ), + imageminLoaderOptions: { + minimizer: { + implementation: ImageMinimizerPlugin.sharpMinify, + filename: "[name]-[width]x[height][ext]", + options: { + encodeOptions: { + png: {}, + }, + }, + }, + generator: [ + { + preset: "webp", + implementation: ImageMinimizerPlugin.sharpGenerate, + filename: "[name]-[width]x[height][ext]", + options: { + encodeOptions: { + webp: {}, + }, + }, + }, + ], + }, + }); + + const { compilation } = stats; + const { warnings, errors } = compilation; + const sizeOf = promisify(imageSize); + + const assetsList = [ + // asset path, width, height, mime regExp + ["./loader-test-500x500.png", 500, 500, /image\/png/i], + ["./loader-test-750x750.png", 750, 750, /image\/png/i], + ["./loader-test-1000x1000.png", 1000, 1000, /image\/png/i], + ["./loader-test-1250x1250.png", 1250, 1250, /image\/png/i], + ["./loader-test-1500x1500.png", 1500, 1500, /image\/png/i], + ["./loader-test-1600x1600.png", 1600, 1600, /image\/png/i], + ["./loader-test-1750x1750.png", 1750, 1750, /image\/png/i], + + ["./loader-test-500x500.webp", 500, 500, /image\/webp/i], + ["./loader-test-750x750.webp", 750, 750, /image\/webp/i], + ["./loader-test-1000x1000.webp", 1000, 1000, /image\/webp/i], + ["./loader-test-1250x1250.webp", 1250, 1250, /image\/webp/i], + ["./loader-test-1500x1500.webp", 1500, 1500, /image\/webp/i], + ["./loader-test-1600x1600.webp", 1600, 1600, /image\/webp/i], + ["./loader-test-1750x1750.webp", 1750, 1750, /image\/webp/i], + ]; + + for (const [assetPath, width, height, mimeRegExp] of assetsList) { + const transformedAsset = path.resolve( + __dirname, + compilation.options.output.path, + assetPath, + ); + + // eslint-disable-next-line no-await-in-loop + const transformedExt = await fileType.fromFile(transformedAsset); + // eslint-disable-next-line no-await-in-loop + const dimensions = await sizeOf(transformedAsset); + + expect(dimensions.width).toBe(width); + expect(dimensions.height).toBe(height); + expect(mimeRegExp.test(transformedExt.mime)).toBe(true); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(0); + } + }); }); describe("resize query (squoosh)", () => { diff --git a/types/index.d.ts b/types/index.d.ts index 09c8314..f756731 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -135,6 +135,7 @@ type BasicTransformerOptions = InferDefaultType | undefined; type ResizeOptions = { width?: number | undefined; height?: number | undefined; + unit?: "px" | "percent" | undefined; enabled?: boolean | undefined; }; type BasicTransformerImplementation = ( diff --git a/types/utils.d.ts b/types/utils.d.ts index 00a8e40..693a8d7 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -22,6 +22,7 @@ export type SharpLib = typeof import("sharp"); export type Sharp = import("sharp").Sharp; export type ResizeOptions = import("sharp").ResizeOptions & { enabled?: boolean; + unit?: "px" | "percent"; }; export type SharpEncodeOptions = { avif?: import("sharp").AvifOptions | undefined;