From 8bada008b34549c2c27b2e277707bec7712fc172 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 09:03:16 +0530 Subject: [PATCH] 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 --- src/main.js | 29 ++++++ src/main/ImageOperations.js | 155 +++++++++++++++++++++++++++++ src/preload.js | 8 +- tests/main/ImageOperations.test.js | 58 +++++++++++ 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 src/main/ImageOperations.js create mode 100644 tests/main/ImageOperations.test.js diff --git a/src/main.js b/src/main.js index 64215be..622d48f 100644 --- a/src/main.js +++ b/src/main.js @@ -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 // ============================================ diff --git a/src/main/ImageOperations.js b/src/main/ImageOperations.js new file mode 100644 index 0000000..69052de --- /dev/null +++ b/src/main/ImageOperations.js @@ -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, +}; diff --git a/src/preload.js b/src/preload.js index 117b137..425483d 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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', diff --git a/tests/main/ImageOperations.test.js b/tests/main/ImageOperations.test.js new file mode 100644 index 0000000..0788207 --- /dev/null +++ b/tests/main/ImageOperations.test.js @@ -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(); + }); +});