noupload 1.0.1 → 1.0.2

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.
@@ -1,12 +1,13 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
- import sharp from 'sharp';
3
+ import { Jimp } from 'jimp';
4
4
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
5
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
6
6
  import { fileResult, info, success } from '../../utils/logger';
7
7
  import { withSpinner } from '../../utils/progress';
8
+ import { writeImage } from '../../utils/image';
8
9
 
9
- const AVAILABLE_FILTERS = ['grayscale', 'sepia', 'blur', 'sharpen', 'negate', 'normalize'];
10
+ const AVAILABLE_FILTERS = ['grayscale', 'sepia', 'blur', 'invert', 'normalize'];
10
11
 
11
12
  export const filters = defineCommand({
12
13
  meta: {
@@ -33,8 +34,8 @@ export const filters = defineCommand({
33
34
  intensity: {
34
35
  type: 'string',
35
36
  alias: 'i',
36
- description: 'Filter intensity (for blur, sharpen)',
37
- default: '3',
37
+ description: 'Filter intensity (for blur)',
38
+ default: '5',
38
39
  },
39
40
  },
40
41
  async run({ args }) {
@@ -55,40 +56,32 @@ export const filters = defineCommand({
55
56
  ensureDir(outputPath);
56
57
 
57
58
  const inputSize = getFileSize(inputPath);
58
- const intensity = Number.parseFloat(args.intensity || '3');
59
+ const intensity = Number.parseInt(args.intensity || '5', 10);
59
60
 
60
61
  await withSpinner(
61
62
  `Applying ${filter} filter...`,
62
63
  async () => {
63
- let pipeline = sharp(inputPath);
64
+ const image = await Jimp.read(inputPath);
64
65
 
65
66
  switch (filter) {
66
67
  case 'grayscale':
67
- pipeline = pipeline.grayscale();
68
+ image.greyscale();
68
69
  break;
69
70
  case 'sepia':
70
- // Sepia effect using recomb matrix
71
- pipeline = pipeline.recomb([
72
- [0.393, 0.769, 0.189],
73
- [0.349, 0.686, 0.168],
74
- [0.272, 0.534, 0.131],
75
- ]);
71
+ image.sepia();
76
72
  break;
77
73
  case 'blur':
78
- pipeline = pipeline.blur(intensity);
74
+ image.blur(intensity);
79
75
  break;
80
- case 'sharpen':
81
- pipeline = pipeline.sharpen(intensity);
82
- break;
83
- case 'negate':
84
- pipeline = pipeline.negate();
76
+ case 'invert':
77
+ image.invert();
85
78
  break;
86
79
  case 'normalize':
87
- pipeline = pipeline.normalize();
80
+ image.normalize();
88
81
  break;
89
82
  }
90
83
 
91
- await pipeline.toFile(outputPath);
84
+ await writeImage(image, outputPath);
92
85
  },
93
86
  'Filter applied successfully'
94
87
  );
@@ -1,11 +1,11 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
+ import { Jimp } from 'jimp';
3
4
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
5
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
5
6
  import { fileResult, info, success } from '../../utils/logger';
6
7
  import { withSpinner } from '../../utils/progress';
7
-
8
- type FitMode = 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
8
+ import { getMimeType, writeImage } from '../../utils/image';
9
9
 
10
10
  export const resize = defineCommand({
11
11
  meta: {
@@ -36,7 +36,7 @@ export const resize = defineCommand({
36
36
  fit: {
37
37
  type: 'string',
38
38
  alias: 'f',
39
- description: 'Fit mode: cover, contain, fill, inside, outside',
39
+ description: 'Fit mode: cover, contain, inside (default: inside)',
40
40
  default: 'inside',
41
41
  },
42
42
  scale: {
@@ -58,15 +58,10 @@ export const resize = defineCommand({
58
58
  ensureDir(outputPath);
59
59
 
60
60
  const inputSize = getFileSize(inputPath);
61
- const fit = (args.fit || 'inside') as FitMode;
62
-
63
- // Lazy load sharp
64
- const { default: sharp } = await import('sharp');
65
61
 
66
- // Get original dimensions
67
- const metadata = await sharp(inputPath).metadata();
68
- const originalWidth = metadata.width || 0;
69
- const originalHeight = metadata.height || 0;
62
+ const image = await Jimp.read(inputPath);
63
+ const originalWidth = image.width;
64
+ const originalHeight = image.height;
70
65
 
71
66
  let targetWidth: number | undefined;
72
67
  let targetHeight: number | undefined;
@@ -87,15 +82,23 @@ export const resize = defineCommand({
87
82
  await withSpinner(
88
83
  'Resizing image...',
89
84
  async () => {
90
- await sharp(inputPath)
91
- .resize(targetWidth, targetHeight, { fit, withoutEnlargement: true })
92
- .toFile(outputPath);
85
+ // Calculate dimensions maintaining aspect ratio if only one dimension specified
86
+ if (targetWidth && !targetHeight) {
87
+ const ratio = targetWidth / originalWidth;
88
+ targetHeight = Math.round(originalHeight * ratio);
89
+ } else if (!targetWidth && targetHeight) {
90
+ const ratio = targetHeight / originalHeight;
91
+ targetWidth = Math.round(originalWidth * ratio);
92
+ }
93
+
94
+ image.resize({ w: targetWidth as number, h: targetHeight as number });
95
+ await writeImage(image, outputPath);
93
96
  },
94
97
  'Image resized successfully'
95
98
  );
96
99
 
97
- const outputMetadata = await sharp(outputPath).metadata();
98
- info(`${originalWidth}x${originalHeight} → ${outputMetadata.width}x${outputMetadata.height}`);
100
+ const outputImage = await Jimp.read(outputPath);
101
+ info(`${originalWidth}x${originalHeight} → ${outputImage.width}x${outputImage.height}`);
99
102
 
100
103
  fileResult(inputPath, outputPath, {
101
104
  before: inputSize,
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
- import sharp from 'sharp';
3
+ import { Jimp } from 'jimp';
4
4
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
5
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
6
6
  import { fileResult, info, success } from '../../utils/logger';
7
7
  import { withSpinner } from '../../utils/progress';
8
+ import { writeImage } from '../../utils/image';
8
9
 
9
10
  export const rotate = defineCommand({
10
11
  meta: {
@@ -54,20 +55,20 @@ export const rotate = defineCommand({
54
55
  await withSpinner(
55
56
  'Rotating image...',
56
57
  async () => {
57
- let pipeline = sharp(inputPath);
58
+ const image = await Jimp.read(inputPath);
58
59
 
59
60
  if (args.angle) {
60
61
  const angle = Number.parseInt(args.angle, 10);
61
- pipeline = pipeline.rotate(angle);
62
+ image.rotate({ deg: angle });
62
63
  }
63
64
 
64
65
  if (args.flip === 'horizontal') {
65
- pipeline = pipeline.flop();
66
+ image.flip({ horizontal: true, vertical: false });
66
67
  } else if (args.flip === 'vertical') {
67
- pipeline = pipeline.flip();
68
+ image.flip({ horizontal: false, vertical: true });
68
69
  }
69
70
 
70
- await pipeline.toFile(outputPath);
71
+ await writeImage(image, outputPath);
71
72
  },
72
73
  'Image rotated successfully'
73
74
  );
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
- import sharp from 'sharp';
3
+ import { Jimp } from 'jimp';
4
4
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
5
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
6
- import { fileResult, success } from '../../utils/logger';
6
+ import { fileResult, info, success } from '../../utils/logger';
7
7
  import { withSpinner } from '../../utils/progress';
8
+ import { writeImage } from '../../utils/image';
8
9
 
9
10
  export const stripMetadata = defineCommand({
10
11
  meta: {
@@ -40,13 +41,16 @@ export const stripMetadata = defineCommand({
40
41
  await withSpinner(
41
42
  'Removing metadata...',
42
43
  async () => {
43
- await sharp(inputPath)
44
- .withMetadata({}) // Empty metadata object strips EXIF
45
- .toFile(outputPath);
44
+ // Jimp automatically strips EXIF data when reading/writing
45
+ // as it only works with raw pixel data
46
+ const image = await Jimp.read(inputPath);
47
+ await writeImage(image, outputPath);
46
48
  },
47
49
  'Metadata removed successfully'
48
50
  );
49
51
 
52
+ info('EXIF and metadata stripped from image');
53
+
50
54
  fileResult(inputPath, outputPath, {
51
55
  before: inputSize,
52
56
  after: getFileSize(outputPath),
@@ -1,10 +1,12 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { extname } from 'node:path';
2
3
  import { defineCommand } from 'citty';
3
- import sharp from 'sharp';
4
+ import { Jimp } from 'jimp';
4
5
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
6
  import { resolvePath } from '../../utils/files';
6
7
  import { info, success } from '../../utils/logger';
7
8
  import { withSpinner } from '../../utils/progress';
9
+ import { getMimeType } from '../../utils/image';
8
10
 
9
11
  export const toBase64 = defineCommand({
10
12
  meta: {
@@ -39,20 +41,12 @@ export const toBase64 = defineCommand({
39
41
  await withSpinner(
40
42
  'Converting to Base64...',
41
43
  async () => {
42
- const metadata = await sharp(inputPath).metadata();
43
- const buffer = await sharp(inputPath).toBuffer();
44
- base64String = buffer.toString('base64');
44
+ const image = await Jimp.read(inputPath);
45
+ mimeType = getMimeType(inputPath);
45
46
 
46
- // Determine MIME type
47
- const formatMap: Record<string, string> = {
48
- jpeg: 'image/jpeg',
49
- png: 'image/png',
50
- webp: 'image/webp',
51
- gif: 'image/gif',
52
- svg: 'image/svg+xml',
53
- avif: 'image/avif',
54
- };
55
- mimeType = formatMap[metadata.format || 'jpeg'] || 'image/jpeg';
47
+ // biome-ignore lint/suspicious/noExplicitAny: Jimp types are complex
48
+ const buffer = await (image as any).getBuffer(mimeType);
49
+ base64String = buffer.toString('base64');
56
50
  },
57
51
  'Conversion complete'
58
52
  );
@@ -1,27 +1,19 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { defineCommand } from 'citty';
4
+ import { Jimp } from 'jimp';
3
5
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
6
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
5
- import { fileResult, success } from '../../utils/logger';
7
+ import { fileResult, info, success, warn } from '../../utils/logger';
6
8
  import { withSpinner } from '../../utils/progress';
9
+ import { writeImage } from '../../utils/image';
7
10
 
8
11
  type Position = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
9
12
 
10
- function getGravity(position: Position): string {
11
- const map: Record<Position, string> = {
12
- center: 'center',
13
- 'top-left': 'northwest',
14
- 'top-right': 'northeast',
15
- 'bottom-left': 'southwest',
16
- 'bottom-right': 'southeast',
17
- };
18
- return map[position] || 'southeast';
19
- }
20
-
21
13
  export const watermark = defineCommand({
22
14
  meta: {
23
15
  name: 'watermark',
24
- description: 'Add a text watermark to an image',
16
+ description: 'Add a text watermark to an image (requires a watermark image)',
25
17
  },
26
18
  args: {
27
19
  input: {
@@ -34,10 +26,10 @@ export const watermark = defineCommand({
34
26
  alias: 'o',
35
27
  description: 'Output file path',
36
28
  },
37
- text: {
29
+ image: {
38
30
  type: 'string',
39
- alias: 't',
40
- description: 'Watermark text',
31
+ alias: 'i',
32
+ description: 'Watermark image file (PNG with transparency recommended)',
41
33
  required: true,
42
34
  },
43
35
  position: {
@@ -51,28 +43,27 @@ export const watermark = defineCommand({
51
43
  description: 'Opacity (0.0-1.0)',
52
44
  default: '0.5',
53
45
  },
54
- size: {
46
+ scale: {
55
47
  type: 'string',
56
48
  alias: 's',
57
- description: 'Font size',
58
- default: '24',
59
- },
60
- color: {
61
- type: 'string',
62
- alias: 'c',
63
- description: 'Text color (hex)',
64
- default: '#ffffff',
49
+ description: 'Scale of watermark relative to image (0.0-1.0)',
50
+ default: '0.2',
65
51
  },
66
52
  },
67
53
  async run({ args }) {
68
54
  try {
69
55
  const input = requireArg(args.input, 'input');
70
- const text = requireArg(args.text, 'text');
56
+ const watermarkImage = requireArg(args.image, 'image');
71
57
 
72
58
  const inputPath = resolvePath(input as string);
59
+ const watermarkPath = resolvePath(watermarkImage);
60
+
73
61
  if (!existsSync(inputPath)) {
74
62
  throw new FileNotFoundError(input as string);
75
63
  }
64
+ if (!existsSync(watermarkPath)) {
65
+ throw new FileNotFoundError(watermarkImage);
66
+ }
76
67
 
77
68
  const outputPath = generateOutputPath(inputPath, args.output, '-watermarked');
78
69
  ensureDir(outputPath);
@@ -80,50 +71,56 @@ export const watermark = defineCommand({
80
71
  const inputSize = getFileSize(inputPath);
81
72
  const position = (args.position || 'bottom-right') as Position;
82
73
  const opacity = Number.parseFloat(args.opacity || '0.5');
83
- const fontSize = Number.parseInt(args.size || '24', 10);
84
- const color = args.color || '#ffffff';
85
-
86
- // Lazy load sharp
87
- const { default: sharp } = await import('sharp');
74
+ const scale = Number.parseFloat(args.scale || '0.2');
88
75
 
89
76
  await withSpinner(
90
77
  'Adding watermark...',
91
78
  async () => {
92
- // Get image dimensions
93
- const metadata = await sharp(inputPath).metadata();
94
- const width = metadata.width || 800;
95
- const height = metadata.height || 600;
79
+ const image = await Jimp.read(inputPath);
80
+ const watermark = await Jimp.read(watermarkPath);
81
+
82
+ // Scale watermark relative to image size
83
+ const watermarkWidth = Math.round(image.width * scale);
84
+ const ratio = watermarkWidth / watermark.width;
85
+ const watermarkHeight = Math.round(watermark.height * ratio);
86
+
87
+ watermark.resize({ w: watermarkWidth, h: watermarkHeight });
88
+ watermark.opacity(opacity);
89
+
90
+ // Calculate position
91
+ let x: number;
92
+ let y: number;
93
+ const margin = 20;
96
94
 
97
- // Create SVG text overlay
98
- const svg = `
99
- <svg width="${width}" height="${height}">
100
- <style>
101
- .watermark {
102
- fill: ${color};
103
- fill-opacity: ${opacity};
104
- font-size: ${fontSize}px;
105
- font-family: sans-serif;
106
- font-weight: bold;
107
- }
108
- </style>
109
- <text
110
- x="${position.includes('right') ? width - 20 : position.includes('left') ? 20 : width / 2}"
111
- y="${position.includes('top') ? fontSize + 20 : position.includes('bottom') ? height - 20 : height / 2}"
112
- text-anchor="${position.includes('right') ? 'end' : position.includes('left') ? 'start' : 'middle'}"
113
- class="watermark"
114
- >${text}</text>
115
- </svg>
116
- `;
95
+ switch (position) {
96
+ case 'top-left':
97
+ x = margin;
98
+ y = margin;
99
+ break;
100
+ case 'top-right':
101
+ x = image.width - watermarkWidth - margin;
102
+ y = margin;
103
+ break;
104
+ case 'bottom-left':
105
+ x = margin;
106
+ y = image.height - watermarkHeight - margin;
107
+ break;
108
+ case 'bottom-right':
109
+ x = image.width - watermarkWidth - margin;
110
+ y = image.height - watermarkHeight - margin;
111
+ break;
112
+ case 'center':
113
+ x = Math.round((image.width - watermarkWidth) / 2);
114
+ y = Math.round((image.height - watermarkHeight) / 2);
115
+ break;
116
+ default:
117
+ x = image.width - watermarkWidth - margin;
118
+ y = image.height - watermarkHeight - margin;
119
+ }
117
120
 
118
- await sharp(inputPath)
119
- .composite([
120
- {
121
- input: Buffer.from(svg),
122
- // biome-ignore lint/suspicious/noExplicitAny: sharp types mismatch with lazy load
123
- gravity: getGravity(position) as any,
124
- },
125
- ])
126
- .toFile(outputPath);
121
+ // Composite watermark onto image
122
+ image.composite(watermark, x, y);
123
+ await writeImage(image, outputPath);
127
124
  },
128
125
  'Watermark added successfully'
129
126
  );
@@ -1,9 +1,10 @@
1
1
  import { defineCommand } from 'citty';
2
- import sharp from 'sharp';
2
+ import { Jimp } from 'jimp';
3
3
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
4
  import { ensureDir, getFileSize, globFiles, resolvePath } from '../../utils/files';
5
5
  import { fileResult, info, success } from '../../utils/logger';
6
6
  import { withSpinner } from '../../utils/progress';
7
+ import { getMimeType } from '../../utils/image';
7
8
 
8
9
  export const fromImages = defineCommand({
9
10
  meta: {
@@ -59,21 +60,20 @@ export const fromImages = defineCommand({
59
60
  const pdf = await PDFDocument.create();
60
61
 
61
62
  for (const imagePath of imageFiles) {
62
- const imageBuffer = await Bun.file(imagePath).arrayBuffer();
63
- const imageBytes = new Uint8Array(imageBuffer);
63
+ const image = await Jimp.read(imagePath);
64
+ const mime = getMimeType(imagePath);
64
65
 
65
- // Determine image type and convert if necessary
66
- const metadata = await sharp(Buffer.from(imageBytes)).metadata();
67
66
  // biome-ignore lint/suspicious/noExplicitAny: complex PDFImage type from lazy load
68
67
  let embeddedImage: any;
69
68
 
70
- if (metadata.format === 'png') {
71
- embeddedImage = await pdf.embedPng(imageBytes);
69
+ if (mime === 'image/png') {
70
+ // biome-ignore lint/suspicious/noExplicitAny: Jimp types are complex
71
+ const pngBuffer = await (image as any).getBuffer('image/png');
72
+ embeddedImage = await pdf.embedPng(pngBuffer);
72
73
  } else {
73
74
  // Convert to JPEG for other formats
74
- const jpegBuffer = await sharp(Buffer.from(imageBytes))
75
- .jpeg({ quality: 90 })
76
- .toBuffer();
75
+ // biome-ignore lint/suspicious/noExplicitAny: Jimp types are complex
76
+ const jpegBuffer = await (image as any).getBuffer('image/jpeg', { quality: 90 });
77
77
  embeddedImage = await pdf.embedJpg(jpegBuffer);
78
78
  }
79
79
 
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
3
  import { PDFDocument } from 'pdf-lib';
4
- import sharp from 'sharp';
4
+ import { Jimp } from 'jimp';
5
5
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
6
6
  import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
7
7
  import { fileResult, success } from '../../utils/logger';
@@ -76,16 +76,21 @@ export const sign = defineCommand({
76
76
  const pdfBytes = await Bun.file(inputPath).arrayBuffer();
77
77
  const pdf = await PDFDocument.load(pdfBytes);
78
78
 
79
- // Process signature image
80
- const signatureBuffer = await Bun.file(signaturePath).arrayBuffer();
81
- const processedSignature = await sharp(Buffer.from(signatureBuffer))
82
- .resize(signatureWidth)
83
- .png()
84
- .toBuffer();
79
+ // Process signature image with Jimp
80
+ const signatureImage = await Jimp.read(signaturePath);
81
+
82
+ // Calculate height to maintain aspect ratio
83
+ const ratio = signatureWidth / signatureImage.width;
84
+ const signatureHeight = Math.round(signatureImage.height * ratio);
85
+
86
+ signatureImage.resize({ w: signatureWidth, h: signatureHeight });
87
+
88
+ // biome-ignore lint/suspicious/noExplicitAny: Jimp types are complex
89
+ const processedSignature = await (signatureImage as any).getBuffer('image/png');
85
90
 
86
91
  // Embed image
87
- const signatureImage = await pdf.embedPng(processedSignature);
88
- const signatureDims = signatureImage.scale(1);
92
+ const embeddedSignature = await pdf.embedPng(processedSignature);
93
+ const signatureDims = embeddedSignature.scale(1);
89
94
 
90
95
  // Determine which page to sign
91
96
  const totalPages = pdf.getPageCount();
@@ -130,7 +135,7 @@ export const sign = defineCommand({
130
135
  }
131
136
 
132
137
  // Draw signature
133
- page.drawImage(signatureImage, {
138
+ page.drawImage(embeddedSignature, {
134
139
  x,
135
140
  y,
136
141
  width: signatureDims.width,
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { defineCommand } from 'citty';
3
3
  import jsQR from 'jsqr';
4
- import sharp from 'sharp';
4
+ import { Jimp } from 'jimp';
5
5
  import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
6
6
  import { resolvePath } from '../../utils/files';
7
7
  import { info, success, warn } from '../../utils/logger';
@@ -47,19 +47,17 @@ export const scan = defineCommand({
47
47
  const result = await withSpinner(
48
48
  'Scanning QR code...',
49
49
  async (): Promise<ScanResult | null> => {
50
- // Load image and convert to raw RGBA data
51
- const image = sharp(inputPath);
52
- const { width, height } = await image.metadata();
50
+ // Load image with Jimp
51
+ const image = await Jimp.read(inputPath);
52
+ const { width, height } = image;
53
53
 
54
54
  if (!width || !height) {
55
55
  throw new Error('Could not read image dimensions');
56
56
  }
57
57
 
58
- // Convert to raw RGBA
59
- const rawData = await image.ensureAlpha().raw().toBuffer();
60
-
61
- // Create Uint8ClampedArray for jsQR
62
- const data = new Uint8ClampedArray(rawData);
58
+ // Get raw RGBA data from Jimp
59
+ // Jimp bitmap.data is already in RGBA format
60
+ const data = new Uint8ClampedArray(image.bitmap.data);
63
61
 
64
62
  // Scan for QR code
65
63
  const qrCode = jsQR(data, width, height);
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  import { renderUsage, runMain } from 'citty';
3
3
  import { main } from './cli';
4
4
  import { hex } from './utils/colors';
@@ -0,0 +1,46 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { extname } from 'node:path';
3
+
4
+ /**
5
+ * Get MIME type from file extension
6
+ */
7
+ export function getMimeType(filePath: string): string {
8
+ const ext = extname(filePath).toLowerCase().slice(1);
9
+ const mimeMap: Record<string, string> = {
10
+ jpg: 'image/jpeg',
11
+ jpeg: 'image/jpeg',
12
+ png: 'image/png',
13
+ gif: 'image/gif',
14
+ bmp: 'image/bmp',
15
+ tiff: 'image/tiff',
16
+ tif: 'image/tiff',
17
+ };
18
+ return mimeMap[ext] || 'image/png';
19
+ }
20
+
21
+ /**
22
+ * Write a Jimp image to a file with proper MIME type detection
23
+ */
24
+ export async function writeImage(
25
+ // biome-ignore lint/suspicious/noExplicitAny: Jimp types are overly complex
26
+ image: any,
27
+ outputPath: string,
28
+ options?: { quality?: number }
29
+ ): Promise<void> {
30
+ const mime = getMimeType(outputPath);
31
+
32
+ let buffer: Buffer;
33
+
34
+ if (mime === 'image/jpeg' && options?.quality) {
35
+ buffer = await image.getBuffer(mime, { quality: options.quality });
36
+ } else {
37
+ buffer = await image.getBuffer(mime);
38
+ }
39
+
40
+ writeFileSync(outputPath, buffer);
41
+ }
42
+
43
+ /**
44
+ * Supported image formats for Jimp
45
+ */
46
+ export const SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'tiff'];