diff --git a/README.md b/README.md index dfc0b40..91fc09b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 8d48c53..dd1d224 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/converter.ts b/src/converter.ts new file mode 100644 index 0000000..44c9831 --- /dev/null +++ b/src/converter.ts @@ -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, + ): Promise { + 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); + } +} diff --git a/src/index.ts b/src/index.ts index e548cfb..f257259 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ import assert from 'node:assert'; import type { RsbuildPlugin } from '@rsbuild/core'; +import { ImageConverterPlugin } from './converter.js'; import { ImageMinimizerPlugin } from './minimizer.js'; -import type { Codecs, Options } from './types.js'; +import type { + Codecs, + ConvertOptions, + OptimizeOptions, + Options, +} from './types.js'; import { withDefaultOptions } from './utils.js'; export type PluginImageCompressOptions = Options[]; @@ -12,6 +18,13 @@ export interface IPluginImageCompress { (options: Options[]): RsbuildPlugin; } +export interface IPluginImageConvert { + (...options: ConvertOptions[]): RsbuildPlugin; + (options: ConvertOptions[]): RsbuildPlugin; +} + +export type IPluginImageOptimize = (options: OptimizeOptions) => RsbuildPlugin; + const castOptions = (args: (Options | Options[])[]): Options[] => { const head = args[0]; // expect [['png', { use: 'jpeg' }]] @@ -27,17 +40,49 @@ const castOptions = (args: (Options | Options[])[]): Options[] => { return ret; }; +const castConvertOptions = ( + args: (ConvertOptions | ConvertOptions[])[], +): ConvertOptions[] => { + const head = args[0]; + if (Array.isArray(head)) { + return head; + } + const ret: ConvertOptions[] = []; + for (const arg of args) { + assert(!Array.isArray(arg)); + ret.push(arg); + } + return ret; +}; + const normalizeOptions = (options: Options[]) => { const opts = options.length ? options : DEFAULT_OPTIONS; - const normalized = opts.map((opt) => withDefaultOptions(opt)); - return normalized; + return opts.map((opt) => withDefaultOptions(opt)); +}; + +const normalizeConvertOptions = ( + options: ConvertOptions[], +): ConvertOptions[] => { + return options.map((option) => { + const baseOptions = withDefaultOptions({ + use: Array.isArray(option.use) ? option.use[0] : option.use, + } as Options); + return { + ...baseOptions, + use: option.use, + to: option.to, + skipIfLarger: option.skipIfLarger, + maxFileSizeKB: option.maxFileSizeKB, + } as ConvertOptions; + }); }; export const PLUGIN_IMAGE_COMPRESS_NAME = 'rsbuild:image-compress'; +export const PLUGIN_IMAGE_CONVERT_NAME = 'rsbuild:image-convert'; +export const PLUGIN_IMAGE_OPTIMIZE_NAME = 'rsbuild:image-optimize'; -export { ImageMinimizerPlugin }; +export { ImageMinimizerPlugin, ImageConverterPlugin }; -/** Options enable by default: {@link DEFAULT_OPTIONS} */ export const pluginImageCompress: IPluginImageCompress = ( ...args ): RsbuildPlugin => ({ @@ -61,3 +106,85 @@ export const pluginImageCompress: IPluginImageCompress = ( }); }, }); + +export const pluginImageConvert: IPluginImageConvert = ( + ...args +): RsbuildPlugin => ({ + name: PLUGIN_IMAGE_CONVERT_NAME, + + setup(api) { + const opts = normalizeConvertOptions(castConvertOptions(args)); + + api.modifyBundlerChain((chain, { isDev }) => { + if (isDev) return; + + chain.plugin('image-converter').use(ImageConverterPlugin, [opts]); + }); + + api.modifyRspackConfig((_, { addRules }) => { + const targetFormats = opts.map((option) => option.to); + const uniqueFormats = [...new Set(targetFormats)]; + + for (const format of uniqueFormats) { + addRules([ + { + test: new RegExp(`\\.${format}$`), + type: 'asset/resource', + }, + ]); + } + }); + }, +}); + +export const pluginImageOptimize: IPluginImageOptimize = ( + options, +): RsbuildPlugin => ({ + name: PLUGIN_IMAGE_OPTIMIZE_NAME, + + setup(api) { + const { convert = [], compress = [] } = options; + const normalizedConvertOptions = normalizeConvertOptions(convert); + + if (convert.length > 0) { + api.modifyBundlerChain((chain, { isDev }) => { + if (isDev) return; + chain + .plugin('image-converter') + .use(ImageConverterPlugin, [normalizedConvertOptions]); + }); + + api.modifyRspackConfig((_, { addRules }) => { + const targetFormats = convert.map((option) => option.to); + const uniqueFormats = [...new Set(targetFormats)]; + + for (const format of uniqueFormats) { + addRules([ + { + test: new RegExp(`\\.${format}$`), + type: 'asset/resource', + }, + ]); + } + }); + } + + if (compress.length > 0) { + const opts = normalizeOptions(castOptions(compress)); + + api.modifyBundlerChain((chain, { isDev }) => { + if (isDev) return; + + chain.optimization.minimize(true); + + for (const opt of opts) { + chain.optimization + .minimizer(`image-compress-${opt.use}`) + .use(ImageMinimizerPlugin, [opt]); + } + }); + } + }, +}); + +export const pluginImageCompressWithConversion = pluginImageOptimize; diff --git a/src/types.ts b/src/types.ts index 1ab9250..05f4107 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,8 +44,58 @@ export interface Codec { export type Codecs = keyof CodecBaseOptions; +export type ConvertibleCodecs = Exclude; + export type OptionCollection = { [K in Codecs]: K | FinalOptionCollection[K]; }; export type Options = OptionCollection[Codecs]; + +export type ConvertOptions = { + use: OneOrMany; + test?: OneOrMany; + include?: OneOrMany; + exclude?: OneOrMany; + to: ConvertibleCodecs; + skipIfLarger?: boolean; + maxFileSizeKB?: number; +} & CodecBaseOptions[T]; + +export interface OptimizeOptions { + convert?: ConvertOptions[]; + compress?: Options[]; +} + +export const DEFAULT_CONVERT_OPTIONS: ConvertOptions[] = [ + { + use: 'png', + to: 'webp', + quality: 80, + skipIfLarger: true, + }, + { + use: 'jpeg', + to: 'webp', + quality: 80, + skipIfLarger: true, + }, + { + use: 'png', + to: 'avif', + quality: 60, + skipIfLarger: true, + }, + { + use: 'jpeg', + to: 'avif', + quality: 60, + skipIfLarger: true, + }, +]; + +export interface MatchObject { + test?: OneOrMany; + include?: OneOrMany; + exclude?: OneOrMany; +} diff --git a/src/utils.ts b/src/utils.ts index 4d0e561..7930319 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import assert from 'node:assert'; +import type { Rspack } from '@rsbuild/core'; import codecs from './codecs.js'; import type { FinalOptions, Options } from './types.js'; @@ -9,3 +10,32 @@ export const withDefaultOptions = (opt: Options): FinalOptions => { assert('test' in ret); return ret; }; + +export const buildError = ( + compiler: Rspack.Compiler, + error: unknown, + file?: string, + context?: string, +) => { + const cause = error instanceof Error ? error : new Error(String(error)); + const message = + file && context + ? `Image conversion failed for "${file}" in "${context}": ${cause.message}` + : `Image conversion failed: ${cause.message}`; + + const ret = new compiler.webpack.WebpackError(message); + + if (error instanceof Error) { + (ret as Error & { error: Error }).error = error; + } + + return ret; +}; + +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; +}; diff --git a/test/assets/other-image.jpeg b/test/assets/other-image.jpeg new file mode 100644 index 0000000..ec535d9 Binary files /dev/null and b/test/assets/other-image.jpeg differ diff --git a/test/assets/other-image.webp b/test/assets/other-image.webp new file mode 100644 index 0000000..c58043b Binary files /dev/null and b/test/assets/other-image.webp differ diff --git a/test/conversion/avif/rsbuild.config.ts b/test/conversion/avif/rsbuild.config.ts new file mode 100644 index 0000000..411f0a1 --- /dev/null +++ b/test/conversion/avif/rsbuild.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginImageConvert } from '../../../src/index'; + +export default defineConfig({ + plugins: [ + pluginImageConvert([ + { + use: 'jpeg', + to: 'avif', + test: /\.(jpg|jpeg)$/, + quality: 80, + }, + ]), + ], + output: { + filename: { + image: '[name][ext]', + }, + }, +}); diff --git a/test/conversion/avif/src/index.js b/test/conversion/avif/src/index.js new file mode 100644 index 0000000..d3547dd --- /dev/null +++ b/test/conversion/avif/src/index.js @@ -0,0 +1,9 @@ +import imageJpeg from '../../../assets/image.jpeg?url'; + +const images = [imageJpeg]; + +for (const image of images) { + const el = new Image(); + el.src = image; + document.body.appendChild(el); +} diff --git a/test/conversion/high-quality/rsbuild.config.ts b/test/conversion/high-quality/rsbuild.config.ts new file mode 100644 index 0000000..ee09469 --- /dev/null +++ b/test/conversion/high-quality/rsbuild.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginImageConvert } from '../../../src/index'; + +export default defineConfig({ + plugins: [ + pluginImageConvert([ + { + use: 'png', + to: 'avif', + quality: 80, + }, + ]), + ], + output: { + filename: { + image: '[name][ext]', + }, + }, +}); diff --git a/test/conversion/high-quality/src/index.js b/test/conversion/high-quality/src/index.js new file mode 100644 index 0000000..311c322 --- /dev/null +++ b/test/conversion/high-quality/src/index.js @@ -0,0 +1,10 @@ +import imageJpeg from '../../../assets/other-image.jpeg?url'; +import imageWebp from '../../../assets/other-image.webp?url'; + +const images = [imageJpeg, imageWebp]; + +for (const image of images) { + const el = new Image(); + el.src = image; + document.body.appendChild(el); +} diff --git a/test/conversion/index.test.ts b/test/conversion/index.test.ts new file mode 100644 index 0000000..913d54a --- /dev/null +++ b/test/conversion/index.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; +import { createRsbuild } from '@rsbuild/core'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function loadConfigFromDir(configDir: string) { + const { loadConfig } = await import('@rsbuild/core'); + return loadConfig({ cwd: configDir }); +} + +test('should convert images to WebP format', async () => { + const configDir = join(__dirname, 'webp'); + const config = await loadConfigFromDir(configDir); + + const rsbuild = await createRsbuild({ + cwd: configDir, + rsbuildConfig: config.content, + }); + + await rsbuild.build(); + + // Check that WebP is bundled + const distDir = join(configDir, 'dist/static/image'); + + // the those webp files should exist + const jpegToWebp = readFileSync(join(distDir, 'image.webp')); + const pngToWebp = readFileSync(join(distDir, 'image.webp')); + + // and not be empty empty + expect(jpegToWebp.length).toBeGreaterThan(0); + expect(pngToWebp.length).toBeGreaterThan(0); + + // and have the right magic bytes that determin type + // some reference, a better one would be better but I cannot find. + // https://skia.googlesource.com/external/github.com/google/wuffs/+/HEAD/release/c/wuffs-v0.3.c + // a bit overkill to check obscure magic bytes + // but it guarantees no bug swaping conversion types, extension & size are no guarantees + expect(jpegToWebp.readUInt32BE(0)).toBe(0x52494646); // RIFF + expect(jpegToWebp.readUInt32BE(8)).toBe(0x57454250); // WEBP + + expect(pngToWebp.readUInt32BE(0)).toBe(0x52494646); // RIFF + expect(pngToWebp.readUInt32BE(8)).toBe(0x57454250); // WEBP +}); + +test('should convert source images to AVIF format', async () => { + const configDir = join(__dirname, 'avif'); + const config = await loadConfigFromDir(configDir); + + const rsbuild = await createRsbuild({ + cwd: configDir, + rsbuildConfig: config.content, + }); + + await rsbuild.build(); + + const distDir = join(configDir, 'dist/static/image'); + + const avifContent = readFileSync(join(distDir, 'image.avif')); + expect(avifContent.length).toBeGreaterThan(0); + + expect(avifContent.readUInt32BE(4)).toBe(0x66747970); //ftyp + expect(avifContent.readUInt32BE(8)).toBe(0x61766966); // avif +}); + +test('should produce smaller files with lower quality settings', async () => { + // TODO: unit tests shall cover lower level functions + const highQualityDir = join(__dirname, 'high-quality'); + const highQualityConfig = await loadConfigFromDir(highQualityDir); + + const rsbuildHighQuality = await createRsbuild({ + cwd: highQualityDir, + rsbuildConfig: highQualityConfig.content, + }); + await rsbuildHighQuality.build(); + + const lowQualityDir = join(__dirname, 'low-quality'); + const lowQualityConfig = await loadConfigFromDir(lowQualityDir); + + const rsbuildLowQuality = await createRsbuild({ + cwd: lowQualityDir, + rsbuildConfig: lowQualityConfig.content, + }); + await rsbuildLowQuality.build(); + + const highQualitySize = readFileSync( + join(highQualityDir, 'dist/static/image/other-image.jpeg'), + ).length; + const lowQualitySize = readFileSync( + join(lowQualityDir, 'dist/static/image/image.webp'), + ).length; + + const highQualitySameformat = readFileSync( + join(highQualityDir, 'dist/static/image/other-image.jpeg'), + ).length; + const lowQualitySameFormat = readFileSync( + join(lowQualityDir, 'dist/static/image/image.webp'), + ).length; + + expect(lowQualitySize).toBeLessThan(highQualitySize); + expect(lowQualitySameFormat).toBeLessThan(highQualitySameformat); +}); + +test('should not convert images in development mode', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const configDir = join(__dirname, 'webp'); + const config = await loadConfigFromDir(configDir); + + const rsbuild = await createRsbuild({ + cwd: configDir, + rsbuildConfig: config.content, + }); + + await rsbuild.build(); + + const distDir = join(configDir, 'dist/static/image'); + + const jpegContent = readFileSync(join(distDir, 'image.jpeg')); + const pngContent = readFileSync(join(distDir, 'image.png')); + + const srcDir = join(__dirname, '../assets'); + const srcJpegContent = readFileSync(join(srcDir, 'image.jpeg')); + const srcPngContent = readFileSync(join(srcDir, 'image.png')); + + // check the original images are bundled + expect(jpegContent.length).toBeGreaterThan(0); + expect(pngContent.length).toBeGreaterThan(0); + + // and that they did not change in size + expect(srcJpegContent.length).toEqual(jpegContent.length); + expect(srcPngContent.length).toEqual(srcPngContent.length); + } finally { + process.env.NODE_ENV = originalEnv; + } +}); diff --git a/test/conversion/low-quality/rsbuild.config.ts b/test/conversion/low-quality/rsbuild.config.ts new file mode 100644 index 0000000..472bbe2 --- /dev/null +++ b/test/conversion/low-quality/rsbuild.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginImageConvert } from '../../../src/index'; + +export default defineConfig({ + plugins: [ + pluginImageConvert([ + { + use: 'jpeg', + to: 'jpeg', + quality: 30, + }, + { + use: 'webp', + to: 'webp', + quality: 30, + }, + ]), + ], + output: { + filename: { + image: '[name][ext]', + }, + }, +}); diff --git a/test/conversion/low-quality/src/index.js b/test/conversion/low-quality/src/index.js new file mode 100644 index 0000000..8e94ee9 --- /dev/null +++ b/test/conversion/low-quality/src/index.js @@ -0,0 +1,10 @@ +import imageJpeg from '../../../assets/image.jpeg?url'; +import imageWebp from '../../../assets/image.webp?url'; + +const images = [imageJpeg, imageWebp]; + +for (const image of images) { + const el = new Image(); + el.src = image; + document.body.appendChild(el); +} diff --git a/test/conversion/webp/rsbuild.config.ts b/test/conversion/webp/rsbuild.config.ts new file mode 100644 index 0000000..f2bcb6f --- /dev/null +++ b/test/conversion/webp/rsbuild.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@rsbuild/core'; +import { pluginImageConvert } from '../../../src/index'; + +export default defineConfig({ + plugins: [ + pluginImageConvert([ + { + use: 'png', + to: 'webp', + quality: 80, + }, + ]), + ], + output: { + filename: { + image: '[name][ext]', + }, + }, +}); diff --git a/test/conversion/webp/src/index.js b/test/conversion/webp/src/index.js new file mode 100644 index 0000000..893a844 --- /dev/null +++ b/test/conversion/webp/src/index.js @@ -0,0 +1,10 @@ +import imageJpeg from '../../../assets/image.jpeg?url'; +import imagePng from '../../../assets/image.png?url'; + +const images = [imagePng, imageJpeg]; + +for (const image of images) { + const el = new Image(); + el.src = image; + document.body.appendChild(el); +} diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..4bc9855 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,30 @@ +// TODO: remove magic numbers from test suite +// TODO: confirm all those early bites are correct +// I don't think they need to be reliable, that's for the image lib to make sure +import { readFileSync } from 'node:fs'; + +export function isWebP(buffer: Buffer): boolean { + return ( + buffer.readUInt32BE(0) === 0x52494646 && // RIFF + buffer.readUInt32BE(8) === 0x57454250 + ); // WEBP +} + +export function isAVIF(buffer: Buffer): boolean { + return ( + buffer.readUInt32BE(4) === 0x66747970 && // ftyp + buffer.readUInt32BE(8) === 0x61766966 + ); // avif +} + +export function isJPEG(buffer: Buffer): boolean { + return buffer.readUInt16BE(0) === 0xffd8; // that's JPEG SOI marker +} + +export function isPNG(buffer: Buffer): boolean { + return buffer.readUInt32BE(0) === 0x89504e47; // PNG sign +} + +export function getFileSize(filePath: string): number { + return readFileSync(filePath).length; +}