qasai 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ import sharp from 'sharp';
2
+ import { optimize } from 'svgo';
3
+ import { readFile, writeFile, stat, mkdir, copyFile } from 'fs/promises';
4
+ import { dirname, extname, basename, join } from 'path';
5
+ import type { CompressOptions, CompressionResult, ImageFormat } from './types.js';
6
+ import {
7
+ compressWithMozjpeg,
8
+ compressWithJpegtran,
9
+ compressWithPngquant,
10
+ compressWithOptipng,
11
+ compressWithGifsicle
12
+ } from './engines.js';
13
+
14
+ const SUPPORTED_FORMATS = ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.tiff', '.svg'];
15
+
16
+ export function isSupportedFormat(file: string): boolean {
17
+ const ext = extname(file).toLowerCase();
18
+ return SUPPORTED_FORMATS.includes(ext);
19
+ }
20
+
21
+ export function getFormat(file: string): ImageFormat {
22
+ const ext = extname(file).toLowerCase().slice(1);
23
+ return ext as ImageFormat;
24
+ }
25
+
26
+ function parseResize(resize: string): { width?: number; height?: number; percent?: number } {
27
+ if (resize.endsWith('%')) {
28
+ return { percent: parseInt(resize) / 100 };
29
+ }
30
+ const [w, h] = resize.split('x').map(Number);
31
+ return { width: w || undefined, height: h || undefined };
32
+ }
33
+
34
+ async function compressSvg(
35
+ inputPath: string,
36
+ outputPath: string,
37
+ options: CompressOptions
38
+ ): Promise<CompressionResult> {
39
+ const originalContent = await readFile(inputPath, 'utf-8');
40
+ const originalSize = Buffer.byteLength(originalContent, 'utf-8');
41
+
42
+ const result = optimize(originalContent, {
43
+ multipass: true,
44
+ plugins: [
45
+ 'preset-default',
46
+ 'removeDimensions',
47
+ {
48
+ name: 'removeAttrs',
49
+ params: {
50
+ attrs: options.keepMetadata ? [] : ['data-.*']
51
+ }
52
+ }
53
+ ]
54
+ });
55
+
56
+ await mkdir(dirname(outputPath), { recursive: true });
57
+ await writeFile(outputPath, result.data);
58
+
59
+ const compressedSize = Buffer.byteLength(result.data, 'utf-8');
60
+ const saved = originalSize - compressedSize;
61
+
62
+ return {
63
+ file: inputPath,
64
+ originalSize,
65
+ compressedSize,
66
+ saved,
67
+ savedPercent: (saved / originalSize) * 100
68
+ };
69
+ }
70
+
71
+ async function needsResize(options: CompressOptions): Promise<boolean> {
72
+ return !!(options.resize || options.maxWidth || options.maxHeight);
73
+ }
74
+
75
+ async function resizeWithSharp(inputPath: string, options: CompressOptions): Promise<Buffer> {
76
+ let image = sharp(inputPath);
77
+ const metadata = await image.metadata();
78
+
79
+ if (options.resize) {
80
+ const { width, height, percent } = parseResize(options.resize);
81
+ if (percent && metadata.width && metadata.height) {
82
+ image = image.resize(
83
+ Math.round(metadata.width * percent),
84
+ Math.round(metadata.height * percent)
85
+ );
86
+ } else if (width || height) {
87
+ image = image.resize(width, height, { fit: 'inside', withoutEnlargement: true });
88
+ }
89
+ }
90
+
91
+ if (options.maxWidth || options.maxHeight) {
92
+ const maxW = options.maxWidth ? parseInt(options.maxWidth) : undefined;
93
+ const maxH = options.maxHeight ? parseInt(options.maxHeight) : undefined;
94
+ image = image.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true });
95
+ }
96
+
97
+ return image.toBuffer();
98
+ }
99
+
100
+ async function compressJpeg(
101
+ inputPath: string,
102
+ outputPath: string,
103
+ options: CompressOptions
104
+ ): Promise<CompressionResult> {
105
+ const engine = options.jpegEngine || 'mozjpeg';
106
+ let actualInput = inputPath;
107
+
108
+ if (await needsResize(options)) {
109
+ const buffer = await resizeWithSharp(inputPath, options);
110
+ const tempPath = outputPath + '.tmp.jpg';
111
+ await mkdir(dirname(tempPath), { recursive: true });
112
+ await sharp(buffer).jpeg({ quality: 100 }).toFile(tempPath);
113
+ actualInput = tempPath;
114
+ }
115
+
116
+ let result: CompressionResult;
117
+
118
+ switch (engine) {
119
+ case 'mozjpeg':
120
+ result = await compressWithMozjpeg(actualInput, outputPath, options);
121
+ break;
122
+ case 'jpegtran':
123
+ result = await compressWithJpegtran(actualInput, outputPath, options);
124
+ break;
125
+ case 'sharp':
126
+ default:
127
+ result = await compressWithSharpJpeg(actualInput, outputPath, options);
128
+ break;
129
+ }
130
+
131
+ if (actualInput !== inputPath) {
132
+ await import('fs/promises').then(fs => fs.unlink(actualInput).catch(() => {}));
133
+ }
134
+
135
+ result.file = inputPath;
136
+ return result;
137
+ }
138
+
139
+ async function compressWithSharpJpeg(
140
+ inputPath: string,
141
+ outputPath: string,
142
+ options: CompressOptions
143
+ ): Promise<CompressionResult> {
144
+ const originalStats = await stat(inputPath);
145
+ const originalSize = originalStats.size;
146
+
147
+ const quality = parseInt(options.quality || '80');
148
+
149
+ await mkdir(dirname(outputPath), { recursive: true });
150
+
151
+ let image = sharp(inputPath);
152
+
153
+ if (!options.keepMetadata) {
154
+ image = image.rotate();
155
+ }
156
+
157
+ await image
158
+ .jpeg({
159
+ quality: options.lossless ? 100 : quality,
160
+ progressive: options.progressive !== false,
161
+ mozjpeg: true
162
+ })
163
+ .toFile(outputPath);
164
+
165
+ const compressedStats = await stat(outputPath);
166
+ const compressedSize = compressedStats.size;
167
+ const saved = originalSize - compressedSize;
168
+
169
+ return {
170
+ file: inputPath,
171
+ originalSize,
172
+ compressedSize,
173
+ saved,
174
+ savedPercent: (saved / originalSize) * 100
175
+ };
176
+ }
177
+
178
+ async function compressPng(
179
+ inputPath: string,
180
+ outputPath: string,
181
+ options: CompressOptions
182
+ ): Promise<CompressionResult> {
183
+ const engine = options.pngEngine || 'pngquant';
184
+ let actualInput = inputPath;
185
+
186
+ if (await needsResize(options)) {
187
+ const buffer = await resizeWithSharp(inputPath, options);
188
+ const tempPath = outputPath + '.tmp.png';
189
+ await mkdir(dirname(tempPath), { recursive: true });
190
+ await sharp(buffer).png().toFile(tempPath);
191
+ actualInput = tempPath;
192
+ }
193
+
194
+ let result: CompressionResult;
195
+
196
+ switch (engine) {
197
+ case 'pngquant':
198
+ result = await compressWithPngquant(actualInput, outputPath, options);
199
+ break;
200
+ case 'optipng':
201
+ result = await compressWithOptipng(actualInput, outputPath, options);
202
+ break;
203
+ case 'sharp':
204
+ default:
205
+ result = await compressWithSharpPng(actualInput, outputPath, options);
206
+ break;
207
+ }
208
+
209
+ if (actualInput !== inputPath) {
210
+ await import('fs/promises').then(fs => fs.unlink(actualInput).catch(() => {}));
211
+ }
212
+
213
+ result.file = inputPath;
214
+ return result;
215
+ }
216
+
217
+ async function compressWithSharpPng(
218
+ inputPath: string,
219
+ outputPath: string,
220
+ options: CompressOptions
221
+ ): Promise<CompressionResult> {
222
+ const originalStats = await stat(inputPath);
223
+ const originalSize = originalStats.size;
224
+
225
+ const quality = parseInt(options.quality || '80');
226
+ const effort = parseInt(options.effort || '6');
227
+
228
+ await mkdir(dirname(outputPath), { recursive: true });
229
+
230
+ let image = sharp(inputPath);
231
+
232
+ if (!options.keepMetadata) {
233
+ image = image.rotate();
234
+ }
235
+
236
+ await image
237
+ .png({
238
+ compressionLevel: Math.min(9, Math.round(effort * 0.9)),
239
+ palette: !options.lossless,
240
+ quality: options.lossless ? 100 : quality,
241
+ effort
242
+ })
243
+ .toFile(outputPath);
244
+
245
+ const compressedStats = await stat(outputPath);
246
+ const compressedSize = compressedStats.size;
247
+ const saved = originalSize - compressedSize;
248
+
249
+ return {
250
+ file: inputPath,
251
+ originalSize,
252
+ compressedSize,
253
+ saved,
254
+ savedPercent: (saved / originalSize) * 100
255
+ };
256
+ }
257
+
258
+ async function compressGif(
259
+ inputPath: string,
260
+ outputPath: string,
261
+ options: CompressOptions
262
+ ): Promise<CompressionResult> {
263
+ const engine = options.gifEngine || 'gifsicle';
264
+
265
+ if (engine === 'gifsicle') {
266
+ return compressWithGifsicle(inputPath, outputPath, options);
267
+ }
268
+
269
+ return compressWithSharpGif(inputPath, outputPath, options);
270
+ }
271
+
272
+ async function compressWithSharpGif(
273
+ inputPath: string,
274
+ outputPath: string,
275
+ options: CompressOptions
276
+ ): Promise<CompressionResult> {
277
+ const originalStats = await stat(inputPath);
278
+ const originalSize = originalStats.size;
279
+
280
+ const effort = parseInt(options.effort || '6');
281
+
282
+ await mkdir(dirname(outputPath), { recursive: true });
283
+
284
+ await sharp(inputPath, { animated: true })
285
+ .gif({ effort })
286
+ .toFile(outputPath);
287
+
288
+ const compressedStats = await stat(outputPath);
289
+ const compressedSize = compressedStats.size;
290
+ const saved = originalSize - compressedSize;
291
+
292
+ return {
293
+ file: inputPath,
294
+ originalSize,
295
+ compressedSize,
296
+ saved,
297
+ savedPercent: (saved / originalSize) * 100
298
+ };
299
+ }
300
+
301
+ async function compressRaster(
302
+ inputPath: string,
303
+ outputPath: string,
304
+ options: CompressOptions
305
+ ): Promise<CompressionResult> {
306
+ const originalStats = await stat(inputPath);
307
+ const originalSize = originalStats.size;
308
+
309
+ let image = sharp(inputPath);
310
+ const metadata = await image.metadata();
311
+
312
+ const quality = parseInt(options.quality || '80');
313
+ const effort = parseInt(options.effort || '6');
314
+
315
+ if (options.resize) {
316
+ const { width, height, percent } = parseResize(options.resize);
317
+ if (percent && metadata.width && metadata.height) {
318
+ image = image.resize(
319
+ Math.round(metadata.width * percent),
320
+ Math.round(metadata.height * percent)
321
+ );
322
+ } else if (width || height) {
323
+ image = image.resize(width, height, { fit: 'inside', withoutEnlargement: true });
324
+ }
325
+ }
326
+
327
+ if (options.maxWidth || options.maxHeight) {
328
+ const maxW = options.maxWidth ? parseInt(options.maxWidth) : undefined;
329
+ const maxH = options.maxHeight ? parseInt(options.maxHeight) : undefined;
330
+ image = image.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true });
331
+ }
332
+
333
+ if (!options.keepMetadata) {
334
+ image = image.rotate();
335
+ }
336
+
337
+ const inputFormat = getFormat(inputPath);
338
+ const outputFormat = options.format || (inputFormat === 'jpeg' ? 'jpg' : inputFormat as string);
339
+
340
+ await mkdir(dirname(outputPath), { recursive: true });
341
+
342
+ switch (outputFormat as string) {
343
+ case 'webp':
344
+ await image
345
+ .webp({
346
+ quality: options.lossless ? 100 : quality,
347
+ lossless: options.lossless || false,
348
+ effort
349
+ })
350
+ .toFile(outputPath);
351
+ break;
352
+
353
+ case 'avif':
354
+ await image
355
+ .avif({
356
+ quality: options.lossless ? 100 : quality,
357
+ lossless: options.lossless || false,
358
+ effort
359
+ })
360
+ .toFile(outputPath);
361
+ break;
362
+
363
+ case 'tiff':
364
+ await image
365
+ .tiff({
366
+ quality: options.lossless ? 100 : quality,
367
+ compression: options.lossless ? 'lzw' : 'jpeg'
368
+ })
369
+ .toFile(outputPath);
370
+ break;
371
+
372
+ default:
373
+ await copyFile(inputPath, outputPath);
374
+ }
375
+
376
+ const compressedStats = await stat(outputPath);
377
+ const compressedSize = compressedStats.size;
378
+ const saved = originalSize - compressedSize;
379
+
380
+ return {
381
+ file: inputPath,
382
+ originalSize,
383
+ compressedSize,
384
+ saved,
385
+ savedPercent: (saved / originalSize) * 100
386
+ };
387
+ }
388
+
389
+ export async function compressImage(
390
+ inputPath: string,
391
+ outputPath: string,
392
+ options: CompressOptions
393
+ ): Promise<CompressionResult> {
394
+ const format = getFormat(inputPath);
395
+
396
+ let finalOutputPath = outputPath;
397
+ if (options.format && options.format !== format) {
398
+ const dir = dirname(outputPath);
399
+ const name = basename(outputPath, extname(outputPath));
400
+ finalOutputPath = join(dir, `${name}.${options.format}`);
401
+ }
402
+
403
+ switch (format) {
404
+ case 'svg':
405
+ return compressSvg(inputPath, finalOutputPath, options);
406
+
407
+ case 'jpg':
408
+ case 'jpeg':
409
+ if (!options.format || options.format === 'jpg') {
410
+ return compressJpeg(inputPath, finalOutputPath, options);
411
+ }
412
+ return compressRaster(inputPath, finalOutputPath, options);
413
+
414
+ case 'png':
415
+ if (!options.format || options.format === 'png') {
416
+ return compressPng(inputPath, finalOutputPath, options);
417
+ }
418
+ return compressRaster(inputPath, finalOutputPath, options);
419
+
420
+ case 'gif':
421
+ if (!options.format) {
422
+ return compressGif(inputPath, finalOutputPath, options);
423
+ }
424
+ return compressRaster(inputPath, finalOutputPath, options);
425
+
426
+ default:
427
+ return compressRaster(inputPath, finalOutputPath, options);
428
+ }
429
+ }
430
+
431
+ export function formatBytes(bytes: number): string {
432
+ if (bytes === 0) return '0 B';
433
+ const k = 1024;
434
+ const sizes = ['B', 'KB', 'MB', 'GB'];
435
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
436
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
437
+ }
@@ -0,0 +1,220 @@
1
+ import { execa } from 'execa';
2
+ import { stat, copyFile, mkdir, readFile, writeFile } from 'fs/promises';
3
+ import { dirname, join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ import type { CompressOptions, CompressionResult } from './types.js';
7
+
8
+ async function getTempPath(ext: string): Promise<string> {
9
+ const tempDir = join(tmpdir(), 'qasai');
10
+ await mkdir(tempDir, { recursive: true });
11
+ return join(tempDir, `${randomUUID()}${ext}`);
12
+ }
13
+
14
+ export async function compressWithMozjpeg(
15
+ inputPath: string,
16
+ outputPath: string,
17
+ options: CompressOptions
18
+ ): Promise<CompressionResult> {
19
+ const originalStats = await stat(inputPath);
20
+ const originalSize = originalStats.size;
21
+
22
+ const mozjpeg = await import('mozjpeg');
23
+ const quality = parseInt(options.quality || '80');
24
+
25
+ await mkdir(dirname(outputPath), { recursive: true });
26
+
27
+ const args = [
28
+ '-quality', options.lossless ? '100' : String(quality),
29
+ '-outfile', outputPath,
30
+ inputPath
31
+ ];
32
+
33
+ if (options.progressive !== false) {
34
+ args.unshift('-progressive');
35
+ }
36
+
37
+ await execa(mozjpeg.default, args);
38
+
39
+ const compressedStats = await stat(outputPath);
40
+ const compressedSize = compressedStats.size;
41
+ const saved = originalSize - compressedSize;
42
+
43
+ return {
44
+ file: inputPath,
45
+ originalSize,
46
+ compressedSize,
47
+ saved,
48
+ savedPercent: (saved / originalSize) * 100
49
+ };
50
+ }
51
+
52
+ export async function compressWithJpegtran(
53
+ inputPath: string,
54
+ outputPath: string,
55
+ options: CompressOptions
56
+ ): Promise<CompressionResult> {
57
+ const originalStats = await stat(inputPath);
58
+ const originalSize = originalStats.size;
59
+
60
+ const jpegtran = await import('jpegtran-bin');
61
+
62
+ await mkdir(dirname(outputPath), { recursive: true });
63
+
64
+ const args = [
65
+ '-optimize',
66
+ '-outfile', outputPath
67
+ ];
68
+
69
+ if (options.progressive !== false) {
70
+ args.push('-progressive');
71
+ }
72
+
73
+ if (!options.keepMetadata) {
74
+ args.push('-copy', 'none');
75
+ } else {
76
+ args.push('-copy', 'all');
77
+ }
78
+
79
+ args.push(inputPath);
80
+
81
+ await execa(jpegtran.default, args);
82
+
83
+ const compressedStats = await stat(outputPath);
84
+ const compressedSize = compressedStats.size;
85
+ const saved = originalSize - compressedSize;
86
+
87
+ return {
88
+ file: inputPath,
89
+ originalSize,
90
+ compressedSize,
91
+ saved,
92
+ savedPercent: (saved / originalSize) * 100
93
+ };
94
+ }
95
+
96
+ export async function compressWithPngquant(
97
+ inputPath: string,
98
+ outputPath: string,
99
+ options: CompressOptions
100
+ ): Promise<CompressionResult> {
101
+ const originalStats = await stat(inputPath);
102
+ const originalSize = originalStats.size;
103
+
104
+ const pngquant = await import('pngquant-bin');
105
+ const quality = options.pngQuality || options.quality || '65-80';
106
+ const colors = parseInt(options.colors || '256');
107
+
108
+ await mkdir(dirname(outputPath), { recursive: true });
109
+
110
+ const args = [
111
+ '--quality', quality.includes('-') ? quality : `0-${quality}`,
112
+ '--speed', '1',
113
+ '--force',
114
+ colors.toString(),
115
+ '--output', outputPath,
116
+ inputPath
117
+ ];
118
+
119
+ try {
120
+ await execa(pngquant.default, args);
121
+ } catch (error: unknown) {
122
+ const execaError = error as { exitCode?: number };
123
+ if (execaError.exitCode === 99) {
124
+ await copyFile(inputPath, outputPath);
125
+ } else {
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ const compressedStats = await stat(outputPath);
131
+ const compressedSize = compressedStats.size;
132
+ const saved = originalSize - compressedSize;
133
+
134
+ return {
135
+ file: inputPath,
136
+ originalSize,
137
+ compressedSize,
138
+ saved,
139
+ savedPercent: (saved / originalSize) * 100
140
+ };
141
+ }
142
+
143
+ export async function compressWithOptipng(
144
+ inputPath: string,
145
+ outputPath: string,
146
+ options: CompressOptions
147
+ ): Promise<CompressionResult> {
148
+ const originalStats = await stat(inputPath);
149
+ const originalSize = originalStats.size;
150
+
151
+ const optipng = await import('optipng-bin');
152
+ const effort = parseInt(options.effort || '2');
153
+
154
+ await mkdir(dirname(outputPath), { recursive: true });
155
+ await copyFile(inputPath, outputPath);
156
+
157
+ const args = [
158
+ `-o${Math.min(7, effort)}`,
159
+ '-silent',
160
+ outputPath
161
+ ];
162
+
163
+ if (!options.keepMetadata) {
164
+ args.push('-strip', 'all');
165
+ }
166
+
167
+ await execa(optipng.default, args);
168
+
169
+ const compressedStats = await stat(outputPath);
170
+ const compressedSize = compressedStats.size;
171
+ const saved = originalSize - compressedSize;
172
+
173
+ return {
174
+ file: inputPath,
175
+ originalSize,
176
+ compressedSize,
177
+ saved,
178
+ savedPercent: (saved / originalSize) * 100
179
+ };
180
+ }
181
+
182
+ export async function compressWithGifsicle(
183
+ inputPath: string,
184
+ outputPath: string,
185
+ options: CompressOptions
186
+ ): Promise<CompressionResult> {
187
+ const originalStats = await stat(inputPath);
188
+ const originalSize = originalStats.size;
189
+
190
+ const gifsicle = await import('gifsicle');
191
+ const colors = parseInt(options.colors || '256');
192
+ const effort = parseInt(options.effort || '3');
193
+
194
+ await mkdir(dirname(outputPath), { recursive: true });
195
+
196
+ const args = [
197
+ `-O${Math.min(3, effort)}`,
198
+ '--colors', String(colors),
199
+ '-o', outputPath,
200
+ inputPath
201
+ ];
202
+
203
+ if (options.lossless) {
204
+ args.splice(args.indexOf('--colors'), 2);
205
+ }
206
+
207
+ await execa(gifsicle.default, args);
208
+
209
+ const compressedStats = await stat(outputPath);
210
+ const compressedSize = compressedStats.size;
211
+ const saved = originalSize - compressedSize;
212
+
213
+ return {
214
+ file: inputPath,
215
+ originalSize,
216
+ compressedSize,
217
+ saved,
218
+ savedPercent: (saved / originalSize) * 100
219
+ };
220
+ }
@@ -0,0 +1,35 @@
1
+ export type JpegEngine = 'mozjpeg' | 'jpegtran' | 'sharp';
2
+ export type PngEngine = 'pngquant' | 'optipng' | 'sharp';
3
+ export type GifEngine = 'gifsicle' | 'sharp';
4
+ export type SvgEngine = 'svgo';
5
+
6
+ export interface CompressOptions {
7
+ output?: string;
8
+ inPlace?: boolean;
9
+ quality?: string;
10
+ lossless?: boolean;
11
+ resize?: string;
12
+ maxWidth?: string;
13
+ maxHeight?: string;
14
+ format?: 'jpg' | 'png' | 'webp' | 'avif';
15
+ recursive?: boolean;
16
+ keepMetadata?: boolean;
17
+ progressive?: boolean;
18
+ effort?: string;
19
+ jpegEngine?: JpegEngine;
20
+ pngEngine?: PngEngine;
21
+ gifEngine?: GifEngine;
22
+ pngQuality?: string;
23
+ colors?: string;
24
+ }
25
+
26
+ export interface CompressionResult {
27
+ file: string;
28
+ originalSize: number;
29
+ compressedSize: number;
30
+ saved: number;
31
+ savedPercent: number;
32
+ }
33
+
34
+ export type ImageFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'svg' | 'tiff';
35
+ export type OutputFormat = 'jpg' | 'png' | 'webp' | 'avif' | 'gif' | 'tiff';
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "resolveJsonModule": true,
13
+ "allowSyntheticDefaultImports": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }