import * as plugins from '../plugins.js'; // Used for smartdelay import type { IEsclCapabilities, IEsclScanStatus, IEsclJobInfo, IScanOptions, IScanResult, TScanFormat, TColorMode, } from '../interfaces/index.js'; /** * eSCL XML namespaces */ const NAMESPACES = { scan: 'http://schemas.hp.com/imaging/escl/2011/05/03', pwg: 'http://www.pwg.org/schemas/2010/12/sm', }; /** * Color mode mappings */ const COLOR_MODE_MAP: Record = { color: 'RGB24', grayscale: 'Grayscale8', blackwhite: 'BlackAndWhite1', }; /** * Format MIME type mappings */ const FORMAT_MIME_MAP: Record = { jpeg: 'image/jpeg', png: 'image/png', pdf: 'application/pdf', }; /** * Helper to make HTTP requests using native fetch (available in Node.js 18+) */ async function httpRequest( url: string, method: 'GET' | 'POST' | 'DELETE', body?: string ): Promise<{ status: number; headers: Record; body: string }> { const options: RequestInit = { method }; if (body) { options.headers = { 'Content-Type': 'text/xml; charset=utf-8' }; options.body = body; } const response = await fetch(url, options); const responseBody = await response.text(); const headers: Record = {}; response.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); return { status: response.status, headers, body: responseBody, }; } /** * eSCL/AirScan protocol client for network scanners */ export class EsclProtocol { private baseUrl: string; private capabilities: IEsclCapabilities | null = null; constructor(address: string, port: number, secure: boolean = false) { const protocol = secure ? 'https' : 'http'; this.baseUrl = `${protocol}://${address}:${port}/eSCL`; } /** * Get scanner capabilities */ public async getCapabilities(): Promise { const response = await httpRequest( `${this.baseUrl}/ScannerCapabilities`, 'GET' ); if (response.status !== 200) { throw new Error(`Failed to get capabilities: HTTP ${response.status}`); } this.capabilities = this.parseCapabilities(response.body); return this.capabilities; } /** * Get scanner status */ public async getStatus(): Promise { const response = await httpRequest( `${this.baseUrl}/ScannerStatus`, 'GET' ); if (response.status !== 200) { throw new Error(`Failed to get status: HTTP ${response.status}`); } return this.parseStatus(response.body); } /** * Submit a scan job * Returns the job URI for tracking */ public async submitScanJob(options: IScanOptions): Promise { const scanSettings = this.buildScanSettings(options); // Use fetch for POST with body since SmartRequest API may not support raw body const response = await fetch(`${this.baseUrl}/ScanJobs`, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', }, body: scanSettings, }); if (response.status !== 201) { throw new Error(`Failed to submit scan job: HTTP ${response.status}`); } // Get job URI from Location header const location = response.headers.get('location'); if (!location) { throw new Error('No job location returned from scanner'); } return location; } /** * Wait for scan job to complete and download the result */ public async waitForScanComplete( jobUri: string, options: IScanOptions, pollInterval: number = 500 ): Promise { // Poll until job is complete let attempts = 0; const maxAttempts = 120; // 60 seconds max while (attempts < maxAttempts) { const status = await this.getStatus(); const job = status.jobs?.find((j) => jobUri.includes(j.jobUuid) || j.jobUri === jobUri); if (job) { if (job.jobState === 'Completed') { break; } else if (job.jobState === 'Canceled' || job.jobState === 'Aborted') { throw new Error(`Scan job ${job.jobState}: ${job.jobStateReason || 'Unknown reason'}`); } } await plugins.smartdelay.delayFor(pollInterval); attempts++; } if (attempts >= maxAttempts) { throw new Error('Scan job timed out'); } // Download the scanned document return this.downloadScan(jobUri, options); } /** * Download scanned document */ public async downloadScan(jobUri: string, options: IScanOptions): Promise { const downloadUrl = `${jobUri}/NextDocument`; const response = await fetch(downloadUrl, { method: 'GET' }); if (response.status !== 200) { throw new Error(`Failed to download scan: HTTP ${response.status}`); } const format = options.format ?? 'jpeg'; const contentType = response.headers.get('content-type') ?? FORMAT_MIME_MAP[format]; // Get image dimensions from headers if available const width = parseInt(response.headers.get('x-image-width') || '0') || 0; const height = parseInt(response.headers.get('x-image-height') || '0') || 0; const arrayBuffer = await response.arrayBuffer(); const data = Buffer.from(arrayBuffer); return { data: data, format: format, width: width, height: height, resolution: options.resolution ?? 300, colorMode: options.colorMode ?? 'color', mimeType: contentType, }; } /** * Cancel a scan job */ public async cancelJob(jobUri: string): Promise { const response = await fetch(jobUri, { method: 'DELETE' }); // 204 No Content or 200 OK are both acceptable if (response.status !== 200 && response.status !== 204) { throw new Error(`Failed to cancel job: HTTP ${response.status}`); } } /** * Build XML scan settings */ private buildScanSettings(options: IScanOptions): string { const resolution = options.resolution ?? 300; const colorMode = COLOR_MODE_MAP[options.colorMode ?? 'color']; const format = FORMAT_MIME_MAP[options.format ?? 'jpeg']; const source = this.mapSource(options.source ?? 'flatbed'); const intent = options.intent ?? 'TextAndPhoto'; let xml = ` 2.0 ${intent} `; if (options.area) { // Convert mm to 300ths of an inch (eSCL uses 300dpi as base unit) const toUnits = (mm: number) => Math.round((mm / 25.4) * 300); xml += ` ${toUnits(options.area.x)} ${toUnits(options.area.y)} ${toUnits(options.area.width)} ${toUnits(options.area.height)}`; } else { // Full page (A4 default: 210x297mm) xml += ` 0 0 2480 3508`; } xml += ` escl:ThreeHundredthsOfInches ${format} ${resolution} ${resolution} ${colorMode} ${source}`; if (options.format === 'jpeg' && options.quality) { xml += ` ${100 - options.quality}`; } xml += ` `; return xml; } /** * Map source to eSCL format */ private mapSource(source: string): string { switch (source) { case 'flatbed': return 'Platen'; case 'adf': return 'Feeder'; case 'adf-duplex': return 'Duplex'; default: return 'Platen'; } } /** * Parse capabilities XML response */ private parseCapabilities(body: string): IEsclCapabilities { const xml = body; // Simple XML parsing without full XML parser const getTagContent = (tag: string): string => { const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); const match = xml.match(regex); return match?.[1]?.trim() ?? ''; }; const getAllTagContents = (tag: string): string[] => { const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'gi'); const matches: string[] = []; let match; while ((match = regex.exec(xml)) !== null) { if (match[1]?.trim()) { matches.push(match[1].trim()); } } return matches; }; const parseResolutions = (): number[] => { const resolutions = getAllTagContents('XResolution'); return [...new Set(resolutions.map((r) => parseInt(r)).filter((r) => !isNaN(r)))]; }; return { version: getTagContent('Version') || '2.0', makeAndModel: getTagContent('MakeAndModel') || getTagContent('Make') || 'Unknown', serialNumber: getTagContent('SerialNumber') || undefined, uuid: getTagContent('UUID') || undefined, adminUri: getTagContent('AdminURI') || undefined, iconUri: getTagContent('IconURI') || undefined, platen: xml.includes('Platen') ? { minWidth: parseInt(getTagContent('MinWidth')) || 0, maxWidth: parseInt(getTagContent('MaxWidth')) || 2550, minHeight: parseInt(getTagContent('MinHeight')) || 0, maxHeight: parseInt(getTagContent('MaxHeight')) || 3508, maxScanRegions: parseInt(getTagContent('MaxScanRegions')) || 1, supportedResolutions: parseResolutions(), colorModes: getAllTagContents('ColorMode'), documentFormats: getAllTagContents('DocumentFormatExt'), } : undefined, adf: xml.includes('Adf') || xml.includes('ADF') || xml.includes('Feeder') ? { minWidth: 0, maxWidth: 2550, minHeight: 0, maxHeight: 4200, maxScanRegions: 1, supportedResolutions: parseResolutions(), colorModes: getAllTagContents('ColorMode'), documentFormats: getAllTagContents('DocumentFormatExt'), } : undefined, }; } /** * Parse status XML response */ private parseStatus(body: string): IEsclScanStatus { const xml = body; const getTagContent = (tag: string): string => { const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); const match = xml.match(regex); return match?.[1]?.trim() ?? ''; }; const state = getTagContent('State') || getTagContent('ScannerState') || 'Idle'; const adfState = getTagContent('AdfState') || undefined; // Parse jobs if present const jobs: IEsclJobInfo[] = []; const jobMatches = xml.match(/<[^:]*:?JobInfo[^>]*>[\s\S]*?<\/[^:]*:?JobInfo>/gi); if (jobMatches) { for (const jobXml of jobMatches) { const getJobTag = (tag: string): string => { const regex = new RegExp(`<[^:]*:?${tag}[^>]*>([^<]*)<`, 'i'); const match = jobXml.match(regex); return match?.[1]?.trim() ?? ''; }; jobs.push({ jobUri: getJobTag('JobUri') || getJobTag('JobURI') || '', jobUuid: getJobTag('JobUuid') || getJobTag('JobUUID') || '', age: parseInt(getJobTag('Age')) || 0, imagesCompleted: parseInt(getJobTag('ImagesCompleted')) || 0, imagesToTransfer: parseInt(getJobTag('ImagesToTransfer')) || 0, jobState: (getJobTag('JobState') as IEsclJobInfo['jobState']) || 'Pending', jobStateReason: getJobTag('JobStateReason') || undefined, }); } } return { state: state as IEsclScanStatus['state'], adfState: adfState as IEsclScanStatus['adfState'], jobs: jobs.length > 0 ? jobs : undefined, }; } /** * Perform a complete scan operation */ public async scan(options: IScanOptions): Promise { // Submit the job const jobUri = await this.submitScanJob(options); try { // Wait for completion and download return await this.waitForScanComplete(jobUri, options); } catch (error) { // Try to cancel the job on error try { await this.cancelJob(jobUri); } catch { // Ignore cancel errors } throw error; } } }