/** * SciPix OCR Client * Client for interacting with SciPix OCR API */ import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { type SciPixConfig, type OCROptions, type OCRResult, type BatchOCRRequest, type BatchOCRResult, type HealthStatus, SciPixError, SciPixErrorCode, OutputFormat, ImageType, } from './types.js'; /** Default configuration */ const DEFAULT_CONFIG: Required> = { baseUrl: 'http://localhost:8080', timeout: 30000, maxRetries: 3, defaultOptions: { formats: [OutputFormat.LaTeX, OutputFormat.Text], detectEquations: true, preprocess: true, }, }; /** SciPix OCR Client */ export class SciPixClient { private config: Required> & { apiKey?: string }; constructor(config?: SciPixConfig) { this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Perform OCR on an image * @param image - Image data as Buffer, base64 string, or file path * @param options - OCR options */ async ocr(image: Buffer | string, options?: OCROptions): Promise { const imageData = await this.prepareImage(image); const mergedOptions = { ...this.config.defaultOptions, ...options }; const response = await this.request('/api/v1/ocr', { method: 'POST', body: JSON.stringify({ image: imageData.base64, imageType: imageData.type, options: mergedOptions, }), }); return response as OCRResult; } /** * Perform OCR on a file * @param filePath - Path to the image file * @param options - OCR options */ async ocrFile(filePath: string, options?: OCROptions): Promise { const buffer = await readFile(filePath); return this.ocr(buffer, options); } /** * Perform batch OCR on multiple images * @param request - Batch OCR request */ async batchOcr(request: BatchOCRRequest): Promise { const response = await this.request('/api/v1/ocr/batch', { method: 'POST', body: JSON.stringify(request), }); return response as BatchOCRResult; } /** * Extract LaTeX from an equation image * @param image - Image data */ async extractLatex(image: Buffer | string): Promise { const result = await this.ocr(image, { formats: [OutputFormat.LaTeX], detectEquations: true, }); return result.latex ?? result.text; } /** * Extract MathML from an equation image * @param image - Image data */ async extractMathML(image: Buffer | string): Promise { const result = await this.ocr(image, { formats: [OutputFormat.MathML], detectEquations: true, }); return result.mathml ?? ''; } /** * Check API health status */ async health(): Promise { const response = await this.request('/api/v1/health', { method: 'GET', }); return response as HealthStatus; } /** * Prepare image for API request */ private async prepareImage( image: Buffer | string, ): Promise<{ base64: string; type: ImageType }> { let buffer: Buffer; let type: ImageType = ImageType.PNG; if (Buffer.isBuffer(image)) { buffer = image; type = this.detectImageType(buffer); } else if (image.startsWith('data:')) { // Base64 data URL const match = image.match(/^data:image\/(\w+);base64,(.+)$/); if (!match) { throw SciPixError.invalidImage('Invalid data URL format'); } type = this.parseImageType(match[1]); return { base64: match[2], type }; } else if (image.startsWith('/') || image.includes(':\\')) { // File path buffer = await readFile(image); type = this.getTypeFromExtension(extname(image)); } else { // Assume base64 string return { base64: image, type: ImageType.PNG }; } return { base64: buffer.toString('base64'), type, }; } /** * Detect image type from buffer magic bytes */ private detectImageType(buffer: Buffer): ImageType { if (buffer[0] === 0x89 && buffer[1] === 0x50) return ImageType.PNG; if (buffer[0] === 0xff && buffer[1] === 0xd8) return ImageType.JPEG; if (buffer[0] === 0x52 && buffer[1] === 0x49) return ImageType.WebP; if (buffer[0] === 0x25 && buffer[1] === 0x50) return ImageType.PDF; if (buffer[0] === 0x49 && buffer[1] === 0x49) return ImageType.TIFF; if (buffer[0] === 0x4d && buffer[1] === 0x4d) return ImageType.TIFF; if (buffer[0] === 0x42 && buffer[1] === 0x4d) return ImageType.BMP; return ImageType.PNG; // Default } /** * Parse image type from MIME type */ private parseImageType(mimeType: string): ImageType { switch (mimeType.toLowerCase()) { case 'png': return ImageType.PNG; case 'jpeg': case 'jpg': return ImageType.JPEG; case 'webp': return ImageType.WebP; case 'pdf': return ImageType.PDF; case 'tiff': case 'tif': return ImageType.TIFF; case 'bmp': return ImageType.BMP; default: return ImageType.PNG; } } /** * Get image type from file extension */ private getTypeFromExtension(ext: string): ImageType { return this.parseImageType(ext.slice(1)); } /** * Make HTTP request to API */ private async request( path: string, options: RequestInit, retries = 0, ): Promise { const url = `${this.config.baseUrl}${path}`; const headers: Record = { 'Content-Type': 'application/json', }; if (this.config.apiKey) { headers['Authorization'] = `Bearer ${this.config.apiKey}`; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); try { const response = await fetch(url, { ...options, headers: { ...headers, ...options.headers }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const error = await response.text(); if (response.status === 401) { throw new SciPixError('Unauthorized', SciPixErrorCode.Unauthorized, 401); } if (response.status === 429) { throw new SciPixError('Rate limited', SciPixErrorCode.RateLimited, 429); } throw SciPixError.serverError(error, response.status); } return await response.json(); } catch (error) { clearTimeout(timeoutId); if (error instanceof SciPixError) { throw error; } if ((error as Error).name === 'AbortError') { throw SciPixError.timeout(); } // Retry on network errors if (retries < this.config.maxRetries) { await new Promise((resolve) => setTimeout(resolve, 1000 * (retries + 1))); return this.request(path, options, retries + 1); } throw SciPixError.networkError((error as Error).message); } } } /** * Create a SciPix client with default configuration */ export function createClient(config?: SciPixConfig): SciPixClient { return new SciPixClient(config); }