Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,76 @@ pluginImageCompress([

For more information on compressors, please visit [@napi-rs/image](https://image.napi.rs/docs).

## Image Conversion

This package also contains a plugin for converting images between modern formats. This allows you to optimize images by converting them to more efficient formats, typically AVIF or WebP.

### Conversion Configuration

```js
pluginImageCompress({
compress: [
{ use: "jpeg" },
{ use: "png" },
],
convert: [
{
use: "png", // Source format(s) to convert from
to: "webp", // Target format to convert to
quality: 80, // Quality setting for the target format
test: /\.png$/, // Regex to match files (same behavior as compress)
include: /\.images/, // Regex to include files (same behavior as compress)
exclude: /\.exclude/, // Regex to exclude files (same behavior as compress)
skipIfLarger: true, // Skip if converted file is larger than original
maxFileSizeKB: 100, // Skip conversion if file size exceeds this limit (KB)
}
]
});
```

### Conversion-Specific Options

The `convert` option accepts an array of conversion rules with the following properties:

- `use`: Source format(s) to convert from (string or array, same behavior as compress)
- `to`: Target format to convert to (supported: `jpeg`, `png`, `avif`, `webp`)
- `quality`: Quality setting for the target format (0-100)
- `skipIfLarger`: Skip conversion if converted file is larger than original
- `maxFileSizeKB`: Skip conversion if original file size exceeds this limit (in KB)

**Note**: The `test`, `include`, and `exclude` options work identically to the compression configuration for file matching.

**Restriction**: SVG and ICO formats (in `use` field) cannot be converted, nor converted into (`to` field).

### Default Conversion Rules

By default, the plugin uses these conversion settings, and would skip if converted size is larger than original.

```js
convert: [
{
use: "png",
to: "webp",
quality: 80,
},
{
use: "jpeg",
to: "webp",
quality: 80,
},
{
use: "png",
to: "avif",
quality: 60,
},
{
use: "jpeg",
to: "avif",
quality: 60,
}
]
```

## Lossless PNG

The default `png` compressor is lossy. If you want to replace it with a lossless compressor, you can use the following configuration.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"lint:write": "biome check . --write",
"prepare": "simple-git-hooks && npm run build",
"test": "playwright test",
"test:basic": "playwright test test/basic/",
"test:conversion": "playwright test test/conversion/",
"bump": "npx bumpp"
},
"simple-git-hooks": {
Expand Down
183 changes: 183 additions & 0 deletions src/converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { Buffer } from 'node:buffer';
import path from 'node:path';
import type { Rspack } from '@rsbuild/core';
import codecs from './codecs.js';
import type { Codecs, ConvertOptions, ConvertibleCodecs } from './types.js';
import { buildError, formatFileSize } from './utils.js';

export const IMAGE_CONVERTER_PLUGIN_NAME = 'rsbuild:image-converter' as const;

export class ImageConverterPlugin {
name = IMAGE_CONVERTER_PLUGIN_NAME;

private options: ConvertOptions[];

constructor(options: ConvertOptions[] | ConvertOptions) {
this.options = Array.isArray(options) ? options : [options];
}

private shouldConvert(
fileName: string,
originalFormat: string,
option: ConvertOptions,
compiler: Rspack.Compiler,
): boolean {
const { matchObject } = compiler.webpack.ModuleFilenameHelpers;

const matchProps = {
test: option.test,
include: option.include,
exclude: option.exclude,
};

if (!matchObject(matchProps, fileName)) {
return false;
}

const useCodecs = Array.isArray(option.use) ? option.use : [option.use];
if (!useCodecs.includes(originalFormat as ConvertibleCodecs)) {
return false;
}

return true;
}

async optimize(
compiler: Rspack.Compiler,
compilation: Rspack.Compilation,
assets: Record<string, Rspack.sources.Source>,
): Promise<void> {
const { RawSource } = compiler.webpack.sources;

const handleAsset = async (name: string) => {
const fileName = name.split('?')[0];
const ext = path
.extname(fileName)
.toLowerCase()
.replace('.', '') as Codecs;

const assetInfo = compilation.getAsset(name)?.info;
if (assetInfo?.converted) {
return;
}

// Find the first matching option for this asset
const matchingOption = this.options.find((option) =>
this.shouldConvert(fileName, ext, option, compiler),
);

if (!matchingOption) {
return;
}

const asset = compilation.getAsset(name);
if (!asset) {
return;
}

const { source: inputSource, info } = asset;
const input = inputSource.source();
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);

if (
matchingOption.maxFileSizeKB &&
buffer.length > matchingOption.maxFileSizeKB * 1024
) {
compilation.warnings.push(
buildError(
compiler,
new Error(
`File too large for conversion (${formatFileSize(buffer.length)} > ${matchingOption.maxFileSizeKB}KB)`,
),
name,
compiler.context,
),
);
return;
}

try {
const targetCodec = codecs[matchingOption.to];
const { to, skipIfLarger, maxFileSizeKB, ...codecOptions } =
matchingOption;
const targetOptions = {
...targetCodec.defaultOptions,
...codecOptions,
};

const convertedBuffer = await targetCodec.handler(
buffer,
targetOptions,
);

if (
matchingOption.skipIfLarger &&
convertedBuffer.length > buffer.length
) {
compilation.warnings.push(
buildError(
compiler,
new Error(
`Conversion resulted in larger file (${formatFileSize(convertedBuffer.length)} > ${formatFileSize(buffer.length)})`,
),
name,
compiler.context,
),
);
return;
}

const convertedPath = this.getConvertedPath(name, matchingOption.to);

compilation.deleteAsset(name);

compilation.emitAsset(convertedPath, new RawSource(convertedBuffer), {
...info,
converted: true,
originalFormat: ext,
convertedFormat: matchingOption.to,
});
} catch (error) {
compilation.errors.push(
buildError(compiler, error, name, compiler.context),
);
}
};

const promises = Object.keys(assets)
.filter((name) => !name.includes('?url') && !name.includes('?inline'))
.map((name) => handleAsset(name));

await Promise.all(promises);
}

private getConvertedPath(originalPath: string, targetFormat: Codecs): string {
const ext = path.extname(originalPath);
return originalPath.replace(new RegExp(`${ext}$`), `.${targetFormat}`);
}

apply(compiler: Rspack.Compiler): void {
const handleCompilation = (compilation: Rspack.Compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: this.name,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE,
},
(assets) => this.optimize(compiler, compilation, assets),
);

compilation.hooks.statsPrinter.tap(this.name, (stats) => {
stats.hooks.print
.for('asset.info.converted')
.tap(
IMAGE_CONVERTER_PLUGIN_NAME,
(converted, { green, formatFlag }) =>
converted && green && formatFlag
? green(formatFlag('converted'))
: '',
);
});
};
compiler.hooks.compilation.tap(this.name, handleCompilation);
}
}
Loading
Loading