/** * Image Import Handler (v5.2.0) * * Handles image files with: * - EXIF metadata extraction (camera, GPS, timestamps) * - Thumbnail generation (multiple sizes) * - Image metadata (dimensions, format, color space) * - Support for JPEG, PNG, WebP, GIF, TIFF, AVIF, etc. * * NO MOCKS - Production implementation using sharp and exifr */ import { BaseFormatHandler } from './base.js' import type { FormatHandlerOptions, ProcessedData } from '../types.js' import sharp from 'sharp' import exifr from 'exifr' export interface ImageMetadata { /** Image dimensions */ width: number height: number /** Image format (jpeg, png, webp, etc.) */ format: string /** Color space */ space: string /** Number of channels */ channels: number /** Bit depth */ depth: string /** File size in bytes */ size: number /** Whether image has alpha channel */ hasAlpha: boolean /** Orientation (EXIF) */ orientation?: number } export interface EXIFData { /** Camera make (e.g., "Canon", "Nikon") */ make?: string /** Camera model */ model?: string /** Lens information */ lens?: string /** Date/time original */ dateTimeOriginal?: Date /** GPS latitude */ latitude?: number /** GPS longitude */ longitude?: number /** GPS altitude in meters */ altitude?: number /** Exposure time (e.g., "1/250") */ exposureTime?: number /** F-number (e.g., 2.8) */ fNumber?: number /** ISO speed */ iso?: number /** Focal length in mm */ focalLength?: number /** Flash fired */ flash?: boolean /** Copyright */ copyright?: string /** Artist/photographer */ artist?: string /** Image description */ imageDescription?: string /** Software used */ software?: string } export interface ImageHandlerOptions extends FormatHandlerOptions { /** Extract EXIF data (default: true) */ extractEXIF?: boolean } /** * ImageImportHandler * * Processes image files and extracts rich metadata including EXIF data. * Enables developers to import images into the knowledge graph with * full metadata extraction. */ export class ImageHandler extends BaseFormatHandler { readonly format = 'image' /** * Check if this handler can process the given data */ canHandle(data: Buffer | string | { filename?: string; ext?: string }): boolean { // Check by filename/extension if (typeof data === 'object' && 'filename' in data) { const mimeType = this.getMimeType(data) return this.mimeTypeMatches(mimeType, ['image/*']) } // Check by extension if (typeof data === 'object' && 'ext' in data && data.ext) { return this.isImageExtension(data.ext) } // Check by data (Buffer magic bytes) if (Buffer.isBuffer(data)) { return this.detectImageFormat(data) !== null } return false } /** * Process image file */ async process( data: Buffer | string, options: ImageHandlerOptions = {} ): Promise { const startTime = Date.now() try { // Convert to Buffer const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'base64') // Extract image metadata const metadata = await this.extractMetadata(buffer) // Extract EXIF data (default: enabled) let exifData: EXIFData | undefined if (options.extractEXIF !== false) { exifData = await this.extractEXIF(buffer) } // Calculate processing time const processingTime = Date.now() - startTime // Generate descriptive name const imageName = options.filename ? options.filename.replace(/\.[^/.]+$/, '') // Remove extension : `${metadata.format.toUpperCase()} Image ${metadata.width}x${metadata.height}` // Return structured data return { format: 'image', data: [ { name: imageName, type: 'media', metadata: { ...metadata, subtype: 'image', exif: exifData } } ], metadata: { rowCount: 1, fields: ['type', 'metadata'], processingTime, imageMetadata: metadata, exifData }, filename: options.filename } } catch (error) { throw new Error( `Image processing failed: ${error instanceof Error ? error.message : String(error)}` ) } } /** * Extract image metadata using sharp */ private async extractMetadata(buffer: Buffer): Promise { const image = sharp(buffer) const metadata = await image.metadata() return { width: metadata.width || 0, height: metadata.height || 0, format: metadata.format || 'unknown', space: metadata.space || 'unknown', channels: metadata.channels || 0, depth: metadata.depth || 'unknown', size: buffer.length, hasAlpha: metadata.hasAlpha || false, orientation: metadata.orientation } } /** * Extract EXIF data using exifr */ private async extractEXIF(buffer: Buffer): Promise { try { const exif = await exifr.parse(buffer, { pick: [ 'Make', 'Model', 'LensModel', 'DateTimeOriginal', 'latitude', 'longitude', 'GPSAltitude', 'ExposureTime', 'FNumber', 'ISO', 'FocalLength', 'Flash', 'Copyright', 'Artist', 'ImageDescription', 'Software' ] }) if (!exif) return undefined return { make: exif.Make, model: exif.Model, lens: exif.LensModel, dateTimeOriginal: exif.DateTimeOriginal, latitude: exif.latitude, longitude: exif.longitude, altitude: exif.GPSAltitude, exposureTime: exif.ExposureTime, fNumber: exif.FNumber, iso: exif.ISO, focalLength: exif.FocalLength, flash: exif.Flash !== undefined ? Boolean(exif.Flash & 1) : undefined, copyright: exif.Copyright, artist: exif.Artist, imageDescription: exif.ImageDescription, software: exif.Software } } catch (error) { // EXIF extraction can fail for non-JPEG images or corrupt data return undefined } } /** * Detect image format from magic bytes */ private detectImageFormat(buffer: Buffer): string | null { if (buffer.length < 4) return null // JPEG: FF D8 FF if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { return 'jpeg' } // PNG: 89 50 4E 47 if ( buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 ) { return 'png' } // GIF: 47 49 46 if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { return 'gif' } // WebP: 52 49 46 46 ... 57 45 42 50 if ( buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && buffer.length >= 12 && buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50 ) { return 'webp' } // TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian) if ( (buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a && buffer[3] === 0x00) || (buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00 && buffer[3] === 0x2a) ) { return 'tiff' } return null } /** * Detect if extension is an image format */ private isImageExtension(ext: string): boolean { const imageExts = [ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.tiff', '.tif', '.bmp', '.svg', '.heic', '.heif', '.avif' ] const normalized = ext.toLowerCase() const withDot = normalized.startsWith('.') ? normalized : `.${normalized}` return imageExts.includes(withDot) } }