pulp-image 0.1.0

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,67 @@
1
+ import { dirname, basename, extname, join, resolve } from 'path';
2
+ import { mkdirSync } from 'fs';
3
+
4
+ /**
5
+ * Builds the output path for an image file
6
+ */
7
+ export function buildOutputPath(inputPath, config, fileIndex = null) {
8
+ const { out, format, width, height, suffix, autoSuffix, renamePattern } = config;
9
+
10
+ // Resolve output directory (but don't create it yet - will be created when writing)
11
+ const outputDir = resolve(out);
12
+
13
+ // Get base filename and extension
14
+ const baseName = basename(inputPath);
15
+ const inputExt = extname(baseName);
16
+ const nameWithoutExt = baseName.slice(0, -inputExt.length);
17
+
18
+ // Determine output extension
19
+ const outputExt = format ? `.${format}` : inputExt;
20
+ const outputExtNoDot = outputExt.slice(1); // Remove leading dot
21
+
22
+ // If rename pattern is provided, use it
23
+ if (renamePattern) {
24
+ let outputFilename = renamePattern
25
+ .replace(/{name}/g, nameWithoutExt)
26
+ .replace(/{ext}/g, outputExtNoDot)
27
+ .replace(/{index}/g, fileIndex !== null ? String(fileIndex + 1) : '1');
28
+
29
+ // Ensure we have an extension
30
+ if (!outputFilename.includes('.')) {
31
+ outputFilename += outputExt;
32
+ }
33
+
34
+ const outputPath = join(outputDir, outputFilename);
35
+ return outputPath;
36
+ }
37
+
38
+ // Original logic (suffix-based naming)
39
+ // Build suffix parts
40
+ const suffixParts = [];
41
+
42
+ // Auto suffix first (if enabled)
43
+ if (autoSuffix) {
44
+ if (width && height) {
45
+ suffixParts.push(`${width}x${height}`);
46
+ } else if (width) {
47
+ suffixParts.push(`${width}w`);
48
+ } else if (height) {
49
+ suffixParts.push(`${height}h`);
50
+ }
51
+ }
52
+
53
+ // Custom suffix second (if provided)
54
+ if (suffix) {
55
+ suffixParts.push(suffix);
56
+ }
57
+
58
+ // Combine suffix
59
+ const finalSuffix = suffixParts.length > 0 ? `-${suffixParts.join('-')}` : '';
60
+
61
+ // Build final filename
62
+ const outputFilename = `${nameWithoutExt}${finalSuffix}${outputExt}`;
63
+ const outputPath = join(outputDir, outputFilename);
64
+
65
+ return outputPath;
66
+ }
67
+
package/src/formats.js ADDED
@@ -0,0 +1,54 @@
1
+ export const SUPPORTED_INPUT_FORMATS = ['png', 'jpg', 'jpeg', 'webp', 'avif'];
2
+ export const SUPPORTED_OUTPUT_FORMATS = ['png', 'jpg', 'webp', 'avif'];
3
+
4
+ export const FORMATS_WITH_TRANSPARENCY = ['png', 'webp', 'avif'];
5
+
6
+ export const DEFAULT_QUALITY = {
7
+ jpg: 80,
8
+ webp: 80,
9
+ avif: 50,
10
+ png: null // PNG is lossless by default
11
+ };
12
+
13
+ export const FORMATS_SUPPORTING_LOSSLESS = ['png', 'webp', 'avif'];
14
+
15
+ /**
16
+ * Validates if a format is supported for output
17
+ */
18
+ export function isValidOutputFormat(format) {
19
+ return format && SUPPORTED_OUTPUT_FORMATS.includes(format.toLowerCase());
20
+ }
21
+
22
+ /**
23
+ * Gets the default quality for a format
24
+ */
25
+ export function getDefaultQuality(format) {
26
+ if (!format) return null;
27
+ return DEFAULT_QUALITY[format.toLowerCase()] || null;
28
+ }
29
+
30
+ /**
31
+ * Checks if a format supports transparency
32
+ */
33
+ export function supportsTransparency(format) {
34
+ if (!format) return false;
35
+ return FORMATS_WITH_TRANSPARENCY.includes(format.toLowerCase());
36
+ }
37
+
38
+ /**
39
+ * Checks if a format supports lossless compression
40
+ */
41
+ export function supportsLossless(format) {
42
+ if (!format) return false;
43
+ return FORMATS_SUPPORTING_LOSSLESS.includes(format.toLowerCase());
44
+ }
45
+
46
+ /**
47
+ * Normalizes format name (jpg -> jpeg for Sharp compatibility)
48
+ */
49
+ export function normalizeFormat(format) {
50
+ if (!format) return null;
51
+ const normalized = format.toLowerCase();
52
+ return normalized === 'jpg' ? 'jpeg' : normalized;
53
+ }
54
+
@@ -0,0 +1,42 @@
1
+ import { readdirSync, statSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+ import { SUPPORTED_INPUT_FORMATS } from './formats.js';
4
+
5
+ /**
6
+ * Plans tasks for processing images in a directory
7
+ * Returns array of file paths to process
8
+ */
9
+ export function planTasks(directoryPath) {
10
+ const resolvedPath = resolve(directoryPath);
11
+ const tasks = [];
12
+
13
+ try {
14
+ const entries = readdirSync(resolvedPath);
15
+
16
+ for (const entry of entries) {
17
+ const entryPath = join(resolvedPath, entry);
18
+
19
+ try {
20
+ const stats = statSync(entryPath);
21
+
22
+ // Only process files (not directories)
23
+ if (stats.isFile()) {
24
+ // Check if file has supported extension
25
+ const ext = entry.split('.').pop()?.toLowerCase();
26
+
27
+ if (ext && SUPPORTED_INPUT_FORMATS.includes(ext)) {
28
+ tasks.push(entryPath);
29
+ }
30
+ }
31
+ } catch (err) {
32
+ // Skip entries we can't stat (permissions, etc.)
33
+ continue;
34
+ }
35
+ }
36
+ } catch (error) {
37
+ throw new Error(`Failed to read directory: ${error.message}`);
38
+ }
39
+
40
+ return tasks;
41
+ }
42
+
@@ -0,0 +1,216 @@
1
+ import sharp from 'sharp';
2
+ import { readFileSync, statSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { buildOutputPath } from './buildOutputPath.js';
5
+ import {
6
+ normalizeFormat,
7
+ supportsTransparency,
8
+ supportsLossless,
9
+ getDefaultQuality,
10
+ isValidOutputFormat
11
+ } from './formats.js';
12
+ import { calculateStats, formatBytes } from './stats.js';
13
+
14
+ /**
15
+ * Processes a single image file
16
+ * @param {string} inputPath - Input file path
17
+ * @param {object} config - Processing configuration
18
+ * @param {number} fileIndex - Optional 0-based index for batch processing (used in rename patterns)
19
+ */
20
+ export async function processImage(inputPath, config, fileIndex = null) {
21
+ const resolvedInputPath = resolve(inputPath);
22
+
23
+ // Check if input file exists
24
+ if (!existsSync(resolvedInputPath)) {
25
+ throw new Error(`Input file not found: ${resolvedInputPath}`);
26
+ }
27
+
28
+ // Get original file size
29
+ const originalStats = statSync(resolvedInputPath);
30
+ const originalSize = originalStats.size;
31
+
32
+ // Read image metadata
33
+ const image = sharp(resolvedInputPath);
34
+ const metadata = await image.metadata();
35
+
36
+ // Determine output format
37
+ const outputFormat = config.format
38
+ ? normalizeFormat(config.format)
39
+ : normalizeFormat(metadata.format);
40
+
41
+ // Validate output format
42
+ if (config.format && !isValidOutputFormat(config.format)) {
43
+ throw new Error(`Unsupported output format: ${config.format}`);
44
+ }
45
+
46
+ // Build output path
47
+ const outputPath = buildOutputPath(resolvedInputPath, config, fileIndex);
48
+
49
+ // Safety check: If input and output paths are the same, require --overwrite
50
+ // This prevents accidental in-place overwrites
51
+ if (resolvedInputPath === outputPath && !config.overwrite) {
52
+ throw new Error(`Input and output paths are the same: ${outputPath}. Use --overwrite to process in-place.`);
53
+ }
54
+
55
+ // Check if output exists and overwrite is not enabled
56
+ if (!config.overwrite && existsSync(outputPath)) {
57
+ throw new Error(`Output file already exists: ${outputPath} (use --overwrite to overwrite)`);
58
+ }
59
+
60
+ // Prepare Sharp pipeline
61
+ let pipeline = image.clone();
62
+
63
+ // Handle resize
64
+ if (config.width || config.height) {
65
+ const resizeOptions = {};
66
+
67
+ if (config.width && config.height) {
68
+ // Both dimensions provided - exact resize
69
+ resizeOptions.width = config.width;
70
+ resizeOptions.height = config.height;
71
+ } else if (config.width) {
72
+ // Only width - preserve aspect ratio
73
+ resizeOptions.width = config.width;
74
+ resizeOptions.height = null;
75
+ } else if (config.height) {
76
+ // Only height - preserve aspect ratio
77
+ resizeOptions.width = null;
78
+ resizeOptions.height = config.height;
79
+ }
80
+
81
+ pipeline = pipeline.resize(resizeOptions);
82
+ }
83
+
84
+ // Handle format conversion and quality
85
+ const formatOptions = {};
86
+
87
+ // Check if output format supports transparency
88
+ const outputSupportsTransparency = supportsTransparency(config.format || metadata.format);
89
+ const inputHasAlpha = metadata.hasAlpha;
90
+
91
+ // Handle transparency for formats that don't support it (JPG)
92
+ if (inputHasAlpha && !outputSupportsTransparency) {
93
+ if (config.alphaMode === 'error') {
94
+ throw new Error(`Input has transparency but output format ${outputFormat} does not support it. Use --alpha-mode flatten or choose a format that supports transparency.`);
95
+ }
96
+
97
+ // Flatten onto background color
98
+ const background = parseColor(config.background);
99
+ pipeline = pipeline.flatten({ background });
100
+ }
101
+
102
+ // Set quality/lossless
103
+ if (config.lossless) {
104
+ if (supportsLossless(outputFormat)) {
105
+ formatOptions.lossless = true;
106
+ } else {
107
+ console.warn(`Warning: Lossless compression not supported for ${outputFormat}, using quality settings instead.`);
108
+ }
109
+ }
110
+
111
+ // Set quality if not lossless or if lossless not supported
112
+ if (!formatOptions.lossless) {
113
+ const quality = config.quality || getDefaultQuality(outputFormat);
114
+ if (quality !== null) {
115
+ formatOptions.quality = quality;
116
+ }
117
+ }
118
+
119
+ // Convert to output format
120
+ switch (outputFormat) {
121
+ case 'png':
122
+ pipeline = pipeline.png(formatOptions);
123
+ break;
124
+ case 'jpeg':
125
+ pipeline = pipeline.jpeg(formatOptions);
126
+ break;
127
+ case 'webp':
128
+ pipeline = pipeline.webp(formatOptions);
129
+ break;
130
+ case 'avif':
131
+ pipeline = pipeline.avif(formatOptions);
132
+ break;
133
+ default:
134
+ // Keep original format
135
+ break;
136
+ }
137
+
138
+ // Ensure output directory exists (only create when about to write)
139
+ const { mkdirSync } = await import('fs');
140
+ const { dirname } = await import('path');
141
+ const outputDir = dirname(outputPath);
142
+ mkdirSync(outputDir, { recursive: true });
143
+
144
+ // Write output
145
+ await pipeline.toFile(outputPath);
146
+
147
+ // Get final file size
148
+ const finalStats = statSync(outputPath);
149
+ const finalSize = finalStats.size;
150
+
151
+ // Calculate statistics
152
+ const stats = calculateStats(originalSize, finalSize);
153
+
154
+ // Handle delete original if requested
155
+ // Safety: Only delete after successful write, and only if paths differ
156
+ // This prevents accidental deletion of the output file or same-path scenarios
157
+ let deleteError = null;
158
+ if (config.deleteOriginal && resolvedInputPath !== outputPath) {
159
+ try {
160
+ const { unlinkSync } = await import('fs');
161
+ // Verify file still exists before attempting deletion
162
+ if (existsSync(resolvedInputPath)) {
163
+ unlinkSync(resolvedInputPath);
164
+ } else {
165
+ console.warn(`Original file already deleted or not found: ${resolvedInputPath}`);
166
+ }
167
+ } catch (err) {
168
+ // Don't fail the entire operation if deletion fails
169
+ // Log warning but continue processing
170
+ console.warn(`Failed to delete original file ${resolvedInputPath}:`, err.message);
171
+ deleteError = err;
172
+ }
173
+ }
174
+
175
+ return {
176
+ inputPath: resolvedInputPath,
177
+ outputPath,
178
+ originalSize,
179
+ finalSize,
180
+ ...stats,
181
+ metadata: {
182
+ width: metadata.width,
183
+ height: metadata.height,
184
+ format: metadata.format,
185
+ hasAlpha: metadata.hasAlpha
186
+ },
187
+ deleteError: deleteError ? deleteError.message : null
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Parses a color string (hex, rgb, etc.) to RGBA object
193
+ * Simple implementation for hex colors
194
+ */
195
+ function parseColor(color) {
196
+ // Handle hex colors (#ffffff, #fff)
197
+ if (color.startsWith('#')) {
198
+ const hex = color.slice(1);
199
+ if (hex.length === 3) {
200
+ // Short hex (#fff -> #ffffff)
201
+ const r = parseInt(hex[0] + hex[0], 16);
202
+ const g = parseInt(hex[1] + hex[1], 16);
203
+ const b = parseInt(hex[2] + hex[2], 16);
204
+ return { r, g, b, alpha: 1 };
205
+ } else if (hex.length === 6) {
206
+ const r = parseInt(hex.slice(0, 2), 16);
207
+ const g = parseInt(hex.slice(2, 4), 16);
208
+ const b = parseInt(hex.slice(4, 6), 16);
209
+ return { r, g, b, alpha: 1 };
210
+ }
211
+ }
212
+
213
+ // Default to white if parsing fails
214
+ return { r: 255, g: 255, b: 255, alpha: 1 };
215
+ }
216
+
@@ -0,0 +1,115 @@
1
+ import chalk from 'chalk';
2
+ import { formatBytes } from './stats.js';
3
+
4
+ /**
5
+ * Reporter class to track processing results
6
+ */
7
+ export class Reporter {
8
+ constructor() {
9
+ this.processed = [];
10
+ this.skipped = [];
11
+ this.failed = [];
12
+ }
13
+
14
+ /**
15
+ * Record a successfully processed file
16
+ */
17
+ recordProcessed(result) {
18
+ this.processed.push(result);
19
+ }
20
+
21
+ /**
22
+ * Record a skipped file
23
+ */
24
+ recordSkipped(filePath, reason) {
25
+ this.skipped.push({ filePath, reason });
26
+ }
27
+
28
+ /**
29
+ * Record a failed file
30
+ */
31
+ recordFailed(filePath, error) {
32
+ this.failed.push({ filePath, error: error.message || String(error) });
33
+ }
34
+
35
+ /**
36
+ * Calculate totals
37
+ */
38
+ getTotals() {
39
+ const totalOriginal = this.processed.reduce((sum, r) => sum + r.originalSize, 0);
40
+ const totalFinal = this.processed.reduce((sum, r) => sum + r.finalSize, 0);
41
+ const totalSaved = totalOriginal - totalFinal;
42
+ const percentSaved = totalOriginal > 0
43
+ ? ((totalSaved / totalOriginal) * 100).toFixed(2)
44
+ : '0.00';
45
+
46
+ return {
47
+ totalOriginal,
48
+ totalFinal,
49
+ totalSaved,
50
+ percentSaved: parseFloat(percentSaved),
51
+ processedCount: this.processed.length,
52
+ skippedCount: this.skipped.length,
53
+ failedCount: this.failed.length
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Print summary report
59
+ */
60
+ printSummary(verbose = false) {
61
+ const totals = this.getTotals();
62
+
63
+ console.log(chalk.cyan('\n' + '='.repeat(60)));
64
+ console.log(chalk.bold.cyan('Processing Summary'));
65
+ console.log(chalk.cyan('='.repeat(60)));
66
+
67
+ // Overall statistics
68
+ if (totals.processedCount > 0) {
69
+ console.log(chalk.green(`\n✓ Processed: ${totals.processedCount} file(s)`));
70
+ console.log(chalk.gray(` Total original size: ${formatBytes(totals.totalOriginal)}`));
71
+ console.log(chalk.gray(` Total final size: ${formatBytes(totals.totalFinal)}`));
72
+ console.log(chalk.gray(` Total saved: ${formatBytes(totals.totalSaved)} (${totals.percentSaved}%)`));
73
+ }
74
+
75
+ // Skipped files
76
+ if (totals.skippedCount > 0) {
77
+ console.log(chalk.yellow(`\n⚠ Skipped: ${totals.skippedCount} file(s)`));
78
+ if (verbose) {
79
+ this.skipped.forEach(({ filePath, reason }) => {
80
+ console.log(chalk.gray(` - ${filePath}: ${reason}`));
81
+ });
82
+ }
83
+ }
84
+
85
+ // Failed files
86
+ if (totals.failedCount > 0) {
87
+ console.log(chalk.red(`\n✗ Failed: ${totals.failedCount} file(s)`));
88
+ if (verbose) {
89
+ this.failed.forEach(({ filePath, error }) => {
90
+ console.log(chalk.gray(` - ${filePath}: ${error}`));
91
+ });
92
+ }
93
+ }
94
+
95
+ console.log(chalk.cyan('\n' + '='.repeat(60)));
96
+ }
97
+
98
+ /**
99
+ * Print per-file results (for verbose mode)
100
+ */
101
+ printFileResult(result, verbose = false) {
102
+ if (verbose) {
103
+ console.log(chalk.green(`\n✓ ${result.outputPath}`));
104
+ console.log(chalk.gray(` Original: ${formatBytes(result.originalSize)} (${result.metadata.width}x${result.metadata.height})`));
105
+ console.log(chalk.gray(` Final: ${formatBytes(result.finalSize)}`));
106
+ console.log(chalk.gray(` Saved: ${formatBytes(result.bytesSaved)} (${result.percentSaved}%)`));
107
+
108
+ // Warn if deletion failed
109
+ if (result.deleteError) {
110
+ console.log(chalk.yellow(` Warning: Failed to delete original: ${result.deleteError}`));
111
+ }
112
+ }
113
+ }
114
+ }
115
+
package/src/runJob.js ADDED
@@ -0,0 +1,97 @@
1
+ import { statSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { processImage } from './processImage.js';
4
+ import { planTasks } from './planTasks.js';
5
+ import { Reporter } from './reporter.js';
6
+
7
+ /**
8
+ * Orchestrates image processing job
9
+ * Returns pure JS object with results (no terminal output)
10
+ *
11
+ * @param {string} inputPath - Input file or directory path
12
+ * @param {object} config - Processing configuration
13
+ * @returns {Promise<object>} Results object with processed, skipped, failed arrays and totals
14
+ */
15
+ export async function runJob(inputPath, config) {
16
+ const resolvedInputPath = resolve(inputPath);
17
+
18
+ // Validate input exists
19
+ if (!existsSync(resolvedInputPath)) {
20
+ throw new Error(`Input not found: ${resolvedInputPath}`);
21
+ }
22
+
23
+ const inputStats = statSync(resolvedInputPath);
24
+ const isDirectory = inputStats.isDirectory();
25
+ const isFile = inputStats.isFile();
26
+
27
+ if (!isFile && !isDirectory) {
28
+ throw new Error(`Input must be a file or directory: ${resolvedInputPath}`);
29
+ }
30
+
31
+ // Initialize reporter for data aggregation (not printing)
32
+ const reporter = new Reporter();
33
+
34
+ if (isFile) {
35
+ // Single file processing
36
+ try {
37
+ const result = await processImage(resolvedInputPath, config, 0);
38
+ reporter.recordProcessed(result);
39
+ } catch (error) {
40
+ // Check if it's a skip (file exists or same path) or a real error
41
+ if (error.message.includes('already exists') || error.message.includes('Input and output paths are the same')) {
42
+ reporter.recordSkipped(resolvedInputPath, error.message);
43
+ } else {
44
+ reporter.recordFailed(resolvedInputPath, error);
45
+ }
46
+ }
47
+ } else {
48
+ // Directory processing
49
+ const tasks = planTasks(resolvedInputPath);
50
+
51
+ if (tasks.length === 0) {
52
+ // Return empty results for empty directory
53
+ return {
54
+ processed: [],
55
+ skipped: [],
56
+ failed: [],
57
+ totals: {
58
+ totalOriginal: 0,
59
+ totalFinal: 0,
60
+ totalSaved: 0,
61
+ percentSaved: 0,
62
+ processedCount: 0,
63
+ skippedCount: 0,
64
+ failedCount: 0
65
+ }
66
+ };
67
+ }
68
+
69
+ // Process each file
70
+ for (let i = 0; i < tasks.length; i++) {
71
+ const filePath = tasks[i];
72
+ try {
73
+ const result = await processImage(filePath, config, i);
74
+ reporter.recordProcessed(result);
75
+ } catch (error) {
76
+ // Check if it's a skip (file exists or same path) or a real error
77
+ if (error.message.includes('already exists') || error.message.includes('Input and output paths are the same')) {
78
+ reporter.recordSkipped(filePath, error.message);
79
+ } else {
80
+ reporter.recordFailed(filePath, error);
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // Get totals from reporter
87
+ const totals = reporter.getTotals();
88
+
89
+ // Return pure JS object (no terminal output)
90
+ return {
91
+ processed: reporter.processed,
92
+ skipped: reporter.skipped,
93
+ failed: reporter.failed,
94
+ totals
95
+ };
96
+ }
97
+
package/src/stats.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Calculates file size statistics
3
+ */
4
+ export function calculateStats(originalSize, finalSize) {
5
+ const bytesSaved = originalSize - finalSize;
6
+ const percentSaved = originalSize > 0
7
+ ? ((bytesSaved / originalSize) * 100).toFixed(2)
8
+ : '0.00';
9
+
10
+ return {
11
+ originalSize,
12
+ finalSize,
13
+ bytesSaved,
14
+ percentSaved: parseFloat(percentSaved)
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Formats bytes to human-readable string
20
+ */
21
+ export function formatBytes(bytes) {
22
+ if (bytes === 0) return '0 B';
23
+
24
+ const k = 1024;
25
+ const sizes = ['B', 'KB', 'MB', 'GB'];
26
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
27
+
28
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
29
+ }
30
+