feat(image): implement sharp-based image operations backend

Add src/main/ImageOperations.js (convert/resize/compress/rotate via
sharp), modeled on PDFOperations.js's executeOperation dispatcher.
Wire ipcMain.handle('process-image-operation', ...) in main.js using
sanitizeErrorMessage() on error paths, and replace the 5 stale/unused
image-* channel names in preload.js's ALLOWED_SEND_CHANNELS with
process-image-operation + select-image-folder (mirroring
select-pdf-folder for a later batch-UI task).

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 949053a7c5
commit 8bada008b3
4 changed files with 245 additions and 5 deletions
+29
View File
@@ -5,6 +5,7 @@ const os = require('os');
const { execFile } = require('child_process');
const WordTemplateExporter = require('./wordTemplateExporter');
const PDFOperations = require('./main/PDFOperations');
const ImageOperations = require('./main/ImageOperations');
const GitOperations = require('./main/GitOperations');
const PdfFontHeader = require('./main/PdfFontHeader');
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
@@ -4620,6 +4621,34 @@ ipcMain.on('select-pdf-folder', (event, inputId) => {
}
});
// ========================================
// IMAGE OPERATIONS — delegates to main/ImageOperations.js
// ========================================
ipcMain.handle('process-image-operation', async (event, { operation, data }) => {
try {
return await ImageOperations.executeOperation(operation, {
...data,
maxFileSize: MAX_FILE_SIZE,
});
} catch (error) {
return { success: false, error: sanitizeErrorMessage(error.message) };
}
});
// IPC Handler for folder selection (for batch image operations)
ipcMain.on('select-image-folder', (event, inputId) => {
const folder = dialog.showOpenDialogSync(mainWindow, {
properties: ['openDirectory'],
});
if (folder && folder[0]) {
event.reply('image-folder-selected', {
inputId,
path: folder[0],
});
}
});
// ============================================
// ASCII Art Generator Window
// ============================================
+155
View File
@@ -0,0 +1,155 @@
/**
* Image Operations Module
*
* Handles image manipulation via `sharp`: format conversion, resize, compress, rotate.
* Mirrors the executeOperation(operation, data) dispatcher pattern used by PDFOperations.js.
*
* @module ImageOperations
*/
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
// Must match the MAX_FILE_SIZE convention defined in main.js (50MB). main.js is the
// single source of truth for this limit; this module does not redefine it independently
// — callers (main.js) pass it in via data.maxFileSize when they want it enforced, and we
// fall back to the same 50MB default so direct/unit-test callers are still protected.
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
const RASTER_FORMATS = ['jpeg', 'png', 'webp', 'avif', 'tiff', 'gif'];
const RESIZE_FIT_MODES = ['cover', 'contain', 'fill', 'inside', 'outside'];
function validateInput(data) {
const { inputPath, outputPath, maxFileSize } = data || {};
if (!inputPath || !outputPath) {
throw new Error('inputPath and outputPath are required');
}
if (!fs.existsSync(inputPath)) {
throw new Error(`Input file not found: ${path.basename(inputPath)}`);
}
const limit = typeof maxFileSize === 'number' ? maxFileSize : DEFAULT_MAX_FILE_SIZE;
const stats = fs.statSync(inputPath);
if (stats.size > limit) {
throw new Error(`File exceeds the ${Math.floor(limit / (1024 * 1024))}MB size limit.`);
}
}
async function imageConvert(data) {
try {
validateInput(data);
const { inputPath, outputPath, format } = data;
if (!RASTER_FORMATS.includes(format)) {
throw new Error(`Unsupported output format: ${format}`);
}
await sharp(inputPath).toFormat(format).toFile(outputPath);
return { success: true, outputPath };
} catch (error) {
throw new Error(`Image conversion failed: ${error.message}`);
}
}
async function imageResize(data) {
try {
validateInput(data);
const { inputPath, outputPath, width = null, height = null, fit = 'inside' } = data;
if (width === null && height === null) {
throw new Error('At least one of width or height must be provided');
}
if (!RESIZE_FIT_MODES.includes(fit)) {
throw new Error(`Unsupported fit mode: ${fit}`);
}
await sharp(inputPath).resize({ width, height, fit }).toFile(outputPath);
return { success: true, outputPath };
} catch (error) {
throw new Error(`Image resize failed: ${error.message}`);
}
}
async function imageCompress(data) {
try {
validateInput(data);
const { inputPath, outputPath, quality = 80 } = data;
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
throw new Error('quality must be an integer between 1 and 100');
}
const ext = path.extname(outputPath).toLowerCase().replace('.', '');
let pipeline = sharp(inputPath);
switch (ext) {
case 'jpg':
case 'jpeg':
pipeline = pipeline.jpeg({ quality });
break;
case 'webp':
pipeline = pipeline.webp({ quality });
break;
case 'avif':
pipeline = pipeline.avif({ quality });
break;
case 'png':
pipeline = pipeline.png({ quality, compressionLevel: 9 });
break;
default:
throw new Error(`Unsupported compression output format: ${ext}`);
}
await pipeline.toFile(outputPath);
return { success: true, outputPath };
} catch (error) {
throw new Error(`Image compression failed: ${error.message}`);
}
}
async function imageRotate(data) {
try {
validateInput(data);
const { inputPath, outputPath, angle } = data;
if (typeof angle !== 'number' || !Number.isFinite(angle)) {
throw new Error('angle must be a number');
}
await sharp(inputPath).rotate(angle).toFile(outputPath);
return { success: true, outputPath };
} catch (error) {
throw new Error(`Image rotation failed: ${error.message}`);
}
}
function executeOperation(operation, data) {
switch (operation) {
case 'convert':
return imageConvert(data);
case 'resize':
return imageResize(data);
case 'compress':
return imageCompress(data);
case 'rotate':
return imageRotate(data);
default:
return Promise.reject(new Error(`Unknown operation: ${operation}`));
}
}
module.exports = {
executeOperation,
imageConvert,
imageResize,
imageCompress,
imageRotate,
};
+3 -5
View File
@@ -45,11 +45,8 @@ const ALLOWED_SEND_CHANNELS = [
'universal-convert-batch',
// Image converter
'image-convert',
'image-batch-convert',
'image-resize',
'image-compress',
'image-rotate',
'process-image-operation',
'select-image-folder',
// Audio converter
'audio-convert',
@@ -205,6 +202,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
// Folder selection
'folder-selected',
'pdf-folder-selected',
'image-folder-selected',
// Header/Footer
'header-footer-settings-data',
+58
View File
@@ -0,0 +1,58 @@
const sharp = require('sharp');
const fs = require('fs');
const os = require('os');
const path = require('path');
const ImageOperations = require('../../src/main/ImageOperations');
describe('ImageOperations', () => {
let tmpDir, inputPath;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'imgops_'));
inputPath = path.join(tmpDir, 'in.png');
await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(inputPath);
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('imageConvert converts PNG to JPEG', async () => {
const outputPath = path.join(tmpDir, 'out.jpg');
const result = await ImageOperations.imageConvert({ inputPath, outputPath, format: 'jpeg' });
expect(result.success).toBe(true);
expect(fs.existsSync(outputPath)).toBe(true);
const meta = await sharp(outputPath).metadata();
expect(meta.format).toBe('jpeg');
});
test('imageResize resizes to given width preserving aspect', async () => {
const outputPath = path.join(tmpDir, 'out.png');
await ImageOperations.imageResize({ inputPath, outputPath, width: 50, height: null, fit: 'inside' });
const meta = await sharp(outputPath).metadata();
expect(meta.width).toBe(50);
});
test('imageRotate rotates by given angle', async () => {
const outputPath = path.join(tmpDir, 'out.png');
await ImageOperations.imageRotate({ inputPath, outputPath, angle: 90 });
const meta = await sharp(outputPath).metadata();
expect(meta.width).toBe(100); // 90deg on square stays square
});
test('imageCompress produces a smaller or equal-size JPEG at low quality', async () => {
const jpegPath = path.join(tmpDir, 'in.jpg');
await sharp(inputPath).jpeg({ quality: 100 }).toFile(jpegPath);
const outputPath = path.join(tmpDir, 'compressed.jpg');
await ImageOperations.imageCompress({ inputPath: jpegPath, outputPath, quality: 10 });
expect(fs.statSync(outputPath).size).toBeLessThanOrEqual(fs.statSync(jpegPath).size);
});
test('executeOperation dispatches to the correct function', async () => {
const outputPath = path.join(tmpDir, 'out.png');
const result = await ImageOperations.executeOperation('rotate', { inputPath, outputPath, angle: 180 });
expect(result.success).toBe(true);
});
test('unknown operation throws', async () => {
await expect(ImageOperations.executeOperation('bogus', {})).rejects.toThrow();
});
});