/** * Scan Feature * Provides document scanning capability using eSCL or SANE protocols */ import { Feature, type TDeviceReference } from './feature.abstract.js'; import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js'; import { SaneProtocol } from '../scanner/scanner.classes.saneprotocol.js'; import type { TScanProtocol, TScanFormat, TColorMode, TScanSource, IScanCapabilities, IScanOptions, IScanResult, IScanFeatureInfo, IFeatureOptions, } from '../interfaces/feature.interfaces.js'; /** * Options for creating a ScanFeature */ export interface IScanFeatureOptions extends IFeatureOptions { protocol: TScanProtocol; secure?: boolean; deviceName?: string; // For SANE supportedFormats?: TScanFormat[]; supportedResolutions?: number[]; supportedColorModes?: TColorMode[]; supportedSources?: TScanSource[]; hasAdf?: boolean; hasDuplex?: boolean; } /** * Scan Feature - provides document scanning capability * * Wraps eSCL (AirScan) or SANE protocols to provide a unified scanning interface. * * @example * ```typescript * const scanFeature = device.getFeature('scan'); * if (scanFeature) { * await scanFeature.connect(); * const result = await scanFeature.scan({ format: 'pdf', resolution: 300 }); * console.log(`Scanned ${result.width}x${result.height} pixels`); * } * ``` */ export class ScanFeature extends Feature { public readonly type = 'scan' as const; public readonly protocol: TScanProtocol; // Protocol clients private esclClient: EsclProtocol | null = null; private saneClient: SaneProtocol | null = null; // Configuration private readonly isSecure: boolean; private readonly deviceName: string; // Capabilities public supportedFormats: TScanFormat[] = ['jpeg', 'png', 'pdf']; public supportedResolutions: number[] = [75, 150, 300, 600]; public supportedColorModes: TColorMode[] = ['color', 'grayscale', 'blackwhite']; public supportedSources: TScanSource[] = ['flatbed']; public hasAdf: boolean = false; public hasDuplex: boolean = false; public maxWidth: number = 215.9; // A4 width in mm public maxHeight: number = 297; // A4 height in mm public minWidth: number = 10; public minHeight: number = 10; constructor( device: TDeviceReference, port: number, options: IScanFeatureOptions ) { super(device, port, options); this.protocol = options.protocol; this.isSecure = options.secure ?? false; this.deviceName = options.deviceName ?? ''; // Set capabilities from options if provided if (options.supportedFormats) this.supportedFormats = options.supportedFormats; if (options.supportedResolutions) this.supportedResolutions = options.supportedResolutions; if (options.supportedColorModes) this.supportedColorModes = options.supportedColorModes; if (options.supportedSources) this.supportedSources = options.supportedSources; if (options.hasAdf !== undefined) this.hasAdf = options.hasAdf; if (options.hasDuplex !== undefined) this.hasDuplex = options.hasDuplex; } // ============================================================================ // Connection // ============================================================================ protected async doConnect(): Promise { if (this.protocol === 'escl') { this.esclClient = new EsclProtocol(this.address, this._port, this.isSecure); // Fetch capabilities to verify connection const caps = await this.esclClient.getCapabilities(); this.updateCapabilitiesFromEscl(caps); } else if (this.protocol === 'sane') { this.saneClient = new SaneProtocol(this.address, this._port); await this.saneClient.connect(); // Open the device if we have a name if (this.deviceName) { await this.saneClient.open(this.deviceName); } } } protected async doDisconnect(): Promise { if (this.saneClient) { try { await this.saneClient.close(); await this.saneClient.disconnect(); } catch { // Ignore disconnect errors } this.saneClient = null; } this.esclClient = null; } // ============================================================================ // Scanning Operations // ============================================================================ /** * Get scanner capabilities */ public async getCapabilities(): Promise { return { resolutions: this.supportedResolutions, formats: this.supportedFormats, colorModes: this.supportedColorModes, sources: this.supportedSources, maxWidth: this.maxWidth, maxHeight: this.maxHeight, minWidth: this.minWidth, minHeight: this.minHeight, }; } /** * Perform a scan */ public async scan(options?: IScanOptions): Promise { if (!this.isConnected) { throw new Error('Scan feature not connected'); } const opts = this.resolveOptions(options); if (this.protocol === 'escl' && this.esclClient) { return this.scanWithEscl(opts); } else if (this.protocol === 'sane' && this.saneClient) { return this.scanWithSane(opts); } throw new Error('No scanner protocol available'); } /** * Cancel an ongoing scan */ public async cancelScan(): Promise { if (this.protocol === 'sane' && this.saneClient) { await this.saneClient.cancel(); } // eSCL cancellation is handled by deleting the job this.emit('scan:cancelled'); } // ============================================================================ // Protocol-Specific Scanning // ============================================================================ private async scanWithEscl(options: Required): Promise { if (!this.esclClient) { throw new Error('eSCL client not initialized'); } this.emit('scan:started', options); // Use the protocol's scan method which handles job submission, // waiting for completion, and downloading in one operation const result = await this.esclClient.scan({ format: options.format, resolution: options.resolution, colorMode: options.colorMode, source: options.source, area: options.area, intent: options.intent, }); this.emit('scan:completed', result); return result; } private async scanWithSane(options: Required): Promise { if (!this.saneClient) { throw new Error('SANE client not initialized'); } this.emit('scan:started', options); // Use the protocol's scan method which handles option configuration, // parameter retrieval, and image reading in one operation const result = await this.saneClient.scan({ format: options.format, resolution: options.resolution, colorMode: options.colorMode, source: options.source, area: options.area, }); this.emit('scan:completed', result); return result; } // ============================================================================ // Helper Methods // ============================================================================ private resolveOptions(options?: IScanOptions): Required { return { resolution: options?.resolution ?? 300, format: options?.format ?? 'jpeg', colorMode: options?.colorMode ?? 'color', source: options?.source ?? 'flatbed', area: options?.area ?? { x: 0, y: 0, width: this.maxWidth, height: this.maxHeight, }, intent: options?.intent ?? 'document', quality: options?.quality ?? 85, }; } private updateCapabilitiesFromEscl(caps: any): void { if (caps.platen) { this.supportedResolutions = caps.platen.supportedResolutions || this.supportedResolutions; this.maxWidth = caps.platen.maxWidth || this.maxWidth; this.maxHeight = caps.platen.maxHeight || this.maxHeight; this.minWidth = caps.platen.minWidth || this.minWidth; this.minHeight = caps.platen.minHeight || this.minHeight; } if (caps.adf) { this.hasAdf = true; if (!this.supportedSources.includes('adf')) { this.supportedSources.push('adf'); } } if (caps.adfDuplex) { this.hasDuplex = true; if (!this.supportedSources.includes('adf-duplex')) { this.supportedSources.push('adf-duplex'); } } } private colorModeToSane(mode: TColorMode): string { switch (mode) { case 'color': return 'Color'; case 'grayscale': return 'Gray'; case 'blackwhite': return 'Lineart'; default: return 'Color'; } } private getMimeType(format: TScanFormat): string { switch (format) { case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'pdf': return 'application/pdf'; case 'tiff': return 'image/tiff'; default: return 'application/octet-stream'; } } // ============================================================================ // Serialization // ============================================================================ public getFeatureInfo(): IScanFeatureInfo { return { ...this.getBaseFeatureInfo(), type: 'scan', protocol: this.protocol, supportedFormats: this.supportedFormats, supportedResolutions: this.supportedResolutions, supportedColorModes: this.supportedColorModes, supportedSources: this.supportedSources, hasAdf: this.hasAdf, hasDuplex: this.hasDuplex, }; } // ============================================================================ // Static Factory // ============================================================================ /** * Create from discovery metadata */ public static fromDiscovery( device: TDeviceReference, port: number, protocol: TScanProtocol, metadata: Record ): ScanFeature { const txtRecords = metadata.txtRecords as Record ?? {}; return new ScanFeature(device, port, { protocol, secure: metadata.secure as boolean ?? port === 443, deviceName: metadata.deviceName as string, supportedFormats: parseFormats(txtRecords), supportedResolutions: parseResolutions(txtRecords), supportedColorModes: parseColorModes(txtRecords), supportedSources: parseSources(txtRecords), hasAdf: Boolean(metadata.hasAdf), hasDuplex: Boolean(metadata.hasDuplex), }); } } // ============================================================================ // Parsing Helpers // ============================================================================ function parseFormats(txt: Record): TScanFormat[] { const pdl = txt['pdl'] || ''; const formats: TScanFormat[] = []; if (pdl.includes('image/jpeg')) formats.push('jpeg'); if (pdl.includes('image/png')) formats.push('png'); if (pdl.includes('application/pdf')) formats.push('pdf'); if (pdl.includes('image/tiff')) formats.push('tiff'); return formats.length > 0 ? formats : ['jpeg', 'png', 'pdf']; } function parseResolutions(txt: Record): number[] { const rs = txt['rs'] || ''; if (!rs) return [75, 150, 300, 600]; return rs.split(',') .map((r) => parseInt(r.trim(), 10)) .filter((r) => !isNaN(r) && r > 0); } function parseColorModes(txt: Record): TColorMode[] { const cs = txt['cs'] || ''; const modes: TColorMode[] = []; if (cs.includes('color') || cs.includes('RGB')) modes.push('color'); if (cs.includes('grayscale') || cs.includes('gray')) modes.push('grayscale'); if (cs.includes('binary') || cs.includes('lineart')) modes.push('blackwhite'); return modes.length > 0 ? modes : ['color', 'grayscale', 'blackwhite']; } function parseSources(txt: Record): TScanSource[] { const is = txt['is'] || ''; const sources: TScanSource[] = []; if (is.includes('platen') || is.includes('flatbed') || !is) sources.push('flatbed'); if (is.includes('adf') && is.includes('duplex')) sources.push('adf-duplex'); else if (is.includes('adf')) sources.push('adf'); return sources.length > 0 ? sources : ['flatbed']; }