371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
|
* Scan Feature
|
|
* Provides document scanning capability using eSCL or SANE protocols
|
|
*/
|
|
|
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
|
import { EsclProtocol, SaneProtocol } from '../protocols/index.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<ScanFeature>('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<void> {
|
|
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<void> {
|
|
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<IScanCapabilities> {
|
|
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<IScanResult> {
|
|
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<void> {
|
|
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<IScanOptions>): Promise<IScanResult> {
|
|
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<IScanOptions>): Promise<IScanResult> {
|
|
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<IScanOptions> {
|
|
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<string, unknown>
|
|
): ScanFeature {
|
|
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
|
|
|
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<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): 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'];
|
|
}
|