mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+29
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user