src / tools / imageTools.ts
src / tools / imageTools.ts
/**
* Image/PDF Processing Tools
* Provides image manipulation, metadata extraction, and PDF merging capabilities.
*/
import { execFile } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import sharp from 'sharp';
import { promisify } from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFileAsync = promisify(execFile);
// Path sandboxing utility - ensures paths stay within workspace
function validatePath(filePath: string, workspaceRoot: string): string {
return validateWorkspacePath(workspaceRoot, workspaceRoot, filePath);
}
// Progress callback type
type ProgressCallback = (progress: { current: number; total?: number }) => void;
// Zod schemas for validation
const imageResizeSchema = z.object({
file_path: z.string().min(1),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
});
const imageConvertFormatSchema = z.object({
input_path: z.string().min(1),
output_path: z.string().min(1),
format: z.enum(['png', 'jpg', 'jpeg', 'webp', 'tiff', 'bmp', 'gif']),
});
const imageExtractMetadataSchema = z.object({
file_path: z.string().min(1),
});
const imageCreateThumbnailSchema = z.object({
input_path: z.string().min(1),
size: z.number().int().positive(),
output_path: z.string().min(1),
});
const pdfMergePagesSchema = z.object({
pdf_paths: z.array(z.string().min(1)),
output_path: z.string().min(1),
});
/**
* Resize an image to specified dimensions.
*/
export async function image_resize(
file_path: string,
width?: number,
height?: number,
workspaceRoot: string = process.cwd(),
progressCallback?: ProgressCallback
): Promise<{ output_path: string; dimensions: { width: number; height: number } }> {
const validated = imageResizeSchema.parse({ file_path, width, height });
// Validate paths are within workspace
const inputPath = validatePath(validated.file_path, workspaceRoot);
const outputPath = validatePath(
path.join(path.dirname(validated.file_path), 'resized_' + path.basename(validated.file_path)),
workspaceRoot,
);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Create image processing pipeline
let pipeline = sharp(inputPath);
if (progressCallback) {
progressCallback({ current: 1, total: 2 });
}
try {
// Apply resize operation
if (width && height) {
pipeline = pipeline.resize(width, height, { fit: 'contain' });
} else if (width) {
pipeline = pipeline.resize({ width });
} else if (height) {
pipeline = pipeline.resize({ height });
}
// Get metadata before saving to determine format
const metadata = await sharp(inputPath).metadata();
// Set output format based on input or default to PNG
let outputFormat = 'png';
if (metadata.format === 'jpeg') {
outputFormat = 'jpg';
} else if (metadata.format === 'webp') {
outputFormat = 'webp';
}
// Save the resized image
await pipeline.toFile(outputPath);
// Get final dimensions
const finalMetadata = await sharp(outputPath).metadata();
if (progressCallback) {
progressCallback({ current: 2, total: 2 });
}
return {
output_path: outputPath,
dimensions: {
width: finalMetadata.width || 0,
height: finalMetadata.height || 0,
},
};
} catch (error) {
throw new Error(`Failed to resize image: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Convert an image to a different format.
*/
export async function image_convert_format(
input_path: string,
output_path: string,
format: 'png' | 'jpg' | 'jpeg' | 'webp' | 'tiff' | 'bmp' | 'gif',
workspaceRoot: string = process.cwd()
): Promise<{ success: boolean; file_size: number }> {
const validated = imageConvertFormatSchema.parse({ input_path, output_path, format });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
try {
await sharp(inputPath).toFormat(format as any).toFile(outputPath);
const stats = await fs.stat(outputPath);
return {
success: true,
file_size: stats.size,
};
} catch (error) {
throw new Error(`Failed to convert image format: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Extract metadata from an image file.
*/
export async function image_extract_metadata(
file_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ exif: Record<string, any>; iptc: Record<string, any>; xmp: Record<string, any> }> {
const validated = imageExtractMetadataSchema.parse({ file_path });
// Validate path is within workspace
const filePath = validatePath(validated.file_path, workspaceRoot);
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`File not found: ${filePath}`);
}
try {
const metadata = await sharp(filePath).metadata();
// Extract EXIF data if available
let exifData: Record<string, any> = {};
let iptcData: Record<string, any> = {};
let xmpData: Record<string, any> = {};
try {
const rawMetadata = await sharp(filePath).metadata();
if (rawMetadata.exif) {
exifData = (rawMetadata.exif as any).toObject ? (rawMetadata.exif as any).toObject() : {};
}
// Try to get IPTC data
if (rawMetadata.iptc) {
iptcData = (rawMetadata.iptc as any).toObject ? (rawMetadata.iptc as any).toObject() : {};
}
// Try to get XMP data
if (rawMetadata.xmp) {
xmpData = { 'xmp': rawMetadata.xmp };
}
} catch {
// Metadata extraction may fail for some formats, that's okay
}
return {
exif: exifData,
iptc: iptcData,
xmp: xmpData,
};
} catch (error) {
throw new Error(`Failed to extract metadata: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a thumbnail from an image.
*/
export async function image_create_thumbnail(
input_path: string,
size: number,
output_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ thumbnail_path: string }> {
const validated = imageCreateThumbnailSchema.parse({ input_path, size, output_path });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
try {
await sharp(inputPath)
.resize(size, size, { fit: 'cover' })
.toFile(outputPath);
return {
thumbnail_path: outputPath,
};
} catch (error) {
throw new Error(`Failed to create thumbnail: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Merge multiple PDF files into a single PDF.
*/
export async function pdf_merge_pages(
pdf_paths: string[],
output_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ merged_pdf_path: string; page_count: number }> {
const validated = pdfMergePagesSchema.parse({ pdf_paths, output_path });
// Validate all paths are within workspace
const resolvedPaths = validated.pdf_paths.map(p => validatePath(p, workspaceRoot));
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
// Check all input files exist
for (const pdfPath of resolvedPaths) {
try {
await fs.access(pdfPath);
} catch (error) {
throw new Error(`PDF file not found: ${pdfPath}`);
}
}
try {
// Use a temporary directory to store intermediate PDFs
const tempDir = path.join(workspaceRoot, '.temp_pdf_merge');
await fs.mkdir(tempDir, { recursive: true });
// Merge PDFs using pdf-lib if available, otherwise use ghostscript as fallback
let mergedPdfPath: string;
try {
// Try to use pdf-lib for merging
const { default: PDFLib } = await import('pdf-lib');
const mergedPdf = (PDFLib as any).PDFDocument.create();
for (const pdfPath of resolvedPaths) {
const pdfBytes = await fs.readFile(pdfPath);
const pdfDoc = await (PDFLib as any).PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdfDoc, (pdfDoc as any).getPageIndices());
copiedPages.forEach((page: any) => mergedPdf.addPage(page));
}
const mergedBytes = await mergedPdf.save();
mergedPdfPath = outputPath;
await fs.writeFile(mergedPdfPath, mergedBytes);
} catch (error) {
// Fallback to ghostscript if pdf-lib fails
try {
const gsCommand = process.platform === 'win32' ? 'gswin64c' : 'gs';
await execFileAsync(gsCommand, [
'-sDEVICE=pdfwrite',
`-sOUTPUTFile=${outputPath}`,
...resolvedPaths
], { timeout: 60000 });
mergedPdfPath = outputPath;
} catch (gsError) {
throw new Error(`Failed to merge PDFs. Please install ghostscript or pdf-lib: ${gsError instanceof Error ? gsError.message : String(gsError)}`);
}
}
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
// Get page count of merged PDF
let pageCount = 0;
try {
const { default: PDFLib } = await import('pdf-lib');
const pdfDoc = await (PDFLib as any).PDFDocument.load(await fs.readFile(mergedPdfPath));
pageCount = (pdfDoc as any).getPageCount();
} catch {
// Fallback page count estimation
pageCount = validated.pdf_paths.length;
}
return {
merged_pdf_path: mergedPdfPath,
page_count: pageCount,
};
} catch (error) {
throw new Error(`Failed to merge PDFs: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Image/PDF Processing Tools
* Provides image manipulation, metadata extraction, and PDF merging capabilities.
*/
import { execFile } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import sharp from 'sharp';
import { promisify } from 'util';
import { z } from 'zod';
import { validatePath as validateWorkspacePath } from '../pathSecurity';
const execFileAsync = promisify(execFile);
// Path sandboxing utility - ensures paths stay within workspace
function validatePath(filePath: string, workspaceRoot: string): string {
return validateWorkspacePath(workspaceRoot, workspaceRoot, filePath);
}
// Progress callback type
type ProgressCallback = (progress: { current: number; total?: number }) => void;
// Zod schemas for validation
const imageResizeSchema = z.object({
file_path: z.string().min(1),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
});
const imageConvertFormatSchema = z.object({
input_path: z.string().min(1),
output_path: z.string().min(1),
format: z.enum(['png', 'jpg', 'jpeg', 'webp', 'tiff', 'bmp', 'gif']),
});
const imageExtractMetadataSchema = z.object({
file_path: z.string().min(1),
});
const imageCreateThumbnailSchema = z.object({
input_path: z.string().min(1),
size: z.number().int().positive(),
output_path: z.string().min(1),
});
const pdfMergePagesSchema = z.object({
pdf_paths: z.array(z.string().min(1)),
output_path: z.string().min(1),
});
/**
* Resize an image to specified dimensions.
*/
export async function image_resize(
file_path: string,
width?: number,
height?: number,
workspaceRoot: string = process.cwd(),
progressCallback?: ProgressCallback
): Promise<{ output_path: string; dimensions: { width: number; height: number } }> {
const validated = imageResizeSchema.parse({ file_path, width, height });
// Validate paths are within workspace
const inputPath = validatePath(validated.file_path, workspaceRoot);
const outputPath = validatePath(
path.join(path.dirname(validated.file_path), 'resized_' + path.basename(validated.file_path)),
workspaceRoot,
);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Create image processing pipeline
let pipeline = sharp(inputPath);
if (progressCallback) {
progressCallback({ current: 1, total: 2 });
}
try {
// Apply resize operation
if (width && height) {
pipeline = pipeline.resize(width, height, { fit: 'contain' });
} else if (width) {
pipeline = pipeline.resize({ width });
} else if (height) {
pipeline = pipeline.resize({ height });
}
// Get metadata before saving to determine format
const metadata = await sharp(inputPath).metadata();
// Set output format based on input or default to PNG
let outputFormat = 'png';
if (metadata.format === 'jpeg') {
outputFormat = 'jpg';
} else if (metadata.format === 'webp') {
outputFormat = 'webp';
}
// Save the resized image
await pipeline.toFile(outputPath);
// Get final dimensions
const finalMetadata = await sharp(outputPath).metadata();
if (progressCallback) {
progressCallback({ current: 2, total: 2 });
}
return {
output_path: outputPath,
dimensions: {
width: finalMetadata.width || 0,
height: finalMetadata.height || 0,
},
};
} catch (error) {
throw new Error(`Failed to resize image: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Convert an image to a different format.
*/
export async function image_convert_format(
input_path: string,
output_path: string,
format: 'png' | 'jpg' | 'jpeg' | 'webp' | 'tiff' | 'bmp' | 'gif',
workspaceRoot: string = process.cwd()
): Promise<{ success: boolean; file_size: number }> {
const validated = imageConvertFormatSchema.parse({ input_path, output_path, format });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
try {
await sharp(inputPath).toFormat(format as any).toFile(outputPath);
const stats = await fs.stat(outputPath);
return {
success: true,
file_size: stats.size,
};
} catch (error) {
throw new Error(`Failed to convert image format: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Extract metadata from an image file.
*/
export async function image_extract_metadata(
file_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ exif: Record<string, any>; iptc: Record<string, any>; xmp: Record<string, any> }> {
const validated = imageExtractMetadataSchema.parse({ file_path });
// Validate path is within workspace
const filePath = validatePath(validated.file_path, workspaceRoot);
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`File not found: ${filePath}`);
}
try {
const metadata = await sharp(filePath).metadata();
// Extract EXIF data if available
let exifData: Record<string, any> = {};
let iptcData: Record<string, any> = {};
let xmpData: Record<string, any> = {};
try {
const rawMetadata = await sharp(filePath).metadata();
if (rawMetadata.exif) {
exifData = (rawMetadata.exif as any).toObject ? (rawMetadata.exif as any).toObject() : {};
}
// Try to get IPTC data
if (rawMetadata.iptc) {
iptcData = (rawMetadata.iptc as any).toObject ? (rawMetadata.iptc as any).toObject() : {};
}
// Try to get XMP data
if (rawMetadata.xmp) {
xmpData = { 'xmp': rawMetadata.xmp };
}
} catch {
// Metadata extraction may fail for some formats, that's okay
}
return {
exif: exifData,
iptc: iptcData,
xmp: xmpData,
};
} catch (error) {
throw new Error(`Failed to extract metadata: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a thumbnail from an image.
*/
export async function image_create_thumbnail(
input_path: string,
size: number,
output_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ thumbnail_path: string }> {
const validated = imageCreateThumbnailSchema.parse({ input_path, size, output_path });
// Validate paths are within workspace
const inputPath = validatePath(validated.input_path, workspaceRoot);
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.access(inputPath);
} catch (error) {
throw new Error(`Input file not found: ${inputPath}`);
}
// Ensure output directory exists
await fs.mkdir(path.dirname(outputPath), { recursive: true });
try {
await sharp(inputPath)
.resize(size, size, { fit: 'cover' })
.toFile(outputPath);
return {
thumbnail_path: outputPath,
};
} catch (error) {
throw new Error(`Failed to create thumbnail: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Merge multiple PDF files into a single PDF.
*/
export async function pdf_merge_pages(
pdf_paths: string[],
output_path: string,
workspaceRoot: string = process.cwd()
): Promise<{ merged_pdf_path: string; page_count: number }> {
const validated = pdfMergePagesSchema.parse({ pdf_paths, output_path });
// Validate all paths are within workspace
const resolvedPaths = validated.pdf_paths.map(p => validatePath(p, workspaceRoot));
const outputPath = validatePath(validated.output_path, workspaceRoot);
try {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
} catch (error) {
throw new Error(`Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`);
}
// Check all input files exist
for (const pdfPath of resolvedPaths) {
try {
await fs.access(pdfPath);
} catch (error) {
throw new Error(`PDF file not found: ${pdfPath}`);
}
}
try {
// Use a temporary directory to store intermediate PDFs
const tempDir = path.join(workspaceRoot, '.temp_pdf_merge');
await fs.mkdir(tempDir, { recursive: true });
// Merge PDFs using pdf-lib if available, otherwise use ghostscript as fallback
let mergedPdfPath: string;
try {
// Try to use pdf-lib for merging
const { default: PDFLib } = await import('pdf-lib');
const mergedPdf = (PDFLib as any).PDFDocument.create();
for (const pdfPath of resolvedPaths) {
const pdfBytes = await fs.readFile(pdfPath);
const pdfDoc = await (PDFLib as any).PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdfDoc, (pdfDoc as any).getPageIndices());
copiedPages.forEach((page: any) => mergedPdf.addPage(page));
}
const mergedBytes = await mergedPdf.save();
mergedPdfPath = outputPath;
await fs.writeFile(mergedPdfPath, mergedBytes);
} catch (error) {
// Fallback to ghostscript if pdf-lib fails
try {
const gsCommand = process.platform === 'win32' ? 'gswin64c' : 'gs';
await execFileAsync(gsCommand, [
'-sDEVICE=pdfwrite',
`-sOUTPUTFile=${outputPath}`,
...resolvedPaths
], { timeout: 60000 });
mergedPdfPath = outputPath;
} catch (gsError) {
throw new Error(`Failed to merge PDFs. Please install ghostscript or pdf-lib: ${gsError instanceof Error ? gsError.message : String(gsError)}`);
}
}
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
// Get page count of merged PDF
let pageCount = 0;
try {
const { default: PDFLib } = await import('pdf-lib');
const pdfDoc = await (PDFLib as any).PDFDocument.load(await fs.readFile(mergedPdfPath));
pageCount = (pdfDoc as any).getPageCount();
} catch {
// Fallback page count estimation
pageCount = validated.pdf_paths.length;
}
return {
merged_pdf_path: mergedPdfPath,
page_count: pageCount,
};
} catch (error) {
throw new Error(`Failed to merge PDFs: ${error instanceof Error ? error.message : String(error)}`);
}
}