initial
This commit is contained in:
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
423
ts/scanner/scanner.classes.esclprotocol.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
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<TColorMode, string> = {
|
||||
color: 'RGB24',
|
||||
grayscale: 'Grayscale8',
|
||||
blackwhite: 'BlackAndWhite1',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format MIME type mappings
|
||||
*/
|
||||
const FORMAT_MIME_MAP: Record<TScanFormat, string> = {
|
||||
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<string, string>; 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<string, string> = {};
|
||||
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<IEsclCapabilities> {
|
||||
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<IEsclScanStatus> {
|
||||
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<string> {
|
||||
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<IScanResult> {
|
||||
// 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<IScanResult> {
|
||||
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<void> {
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scan:ScanSettings xmlns:scan="${NAMESPACES.scan}" xmlns:pwg="${NAMESPACES.pwg}">
|
||||
<pwg:Version>2.0</pwg:Version>
|
||||
<scan:Intent>${intent}</scan:Intent>
|
||||
<pwg:ScanRegions>
|
||||
<pwg:ScanRegion>`;
|
||||
|
||||
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 += `
|
||||
<pwg:XOffset>${toUnits(options.area.x)}</pwg:XOffset>
|
||||
<pwg:YOffset>${toUnits(options.area.y)}</pwg:YOffset>
|
||||
<pwg:Width>${toUnits(options.area.width)}</pwg:Width>
|
||||
<pwg:Height>${toUnits(options.area.height)}</pwg:Height>`;
|
||||
} else {
|
||||
// Full page (A4 default: 210x297mm)
|
||||
xml += `
|
||||
<pwg:XOffset>0</pwg:XOffset>
|
||||
<pwg:YOffset>0</pwg:YOffset>
|
||||
<pwg:Width>2480</pwg:Width>
|
||||
<pwg:Height>3508</pwg:Height>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
<pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits>
|
||||
</pwg:ScanRegion>
|
||||
</pwg:ScanRegions>
|
||||
<scan:DocumentFormatExt>${format}</scan:DocumentFormatExt>
|
||||
<scan:XResolution>${resolution}</scan:XResolution>
|
||||
<scan:YResolution>${resolution}</scan:YResolution>
|
||||
<scan:ColorMode>${colorMode}</scan:ColorMode>
|
||||
<scan:InputSource>${source}</scan:InputSource>`;
|
||||
|
||||
if (options.format === 'jpeg' && options.quality) {
|
||||
xml += `
|
||||
<scan:CompressionFactor>${100 - options.quality}</scan:CompressionFactor>`;
|
||||
}
|
||||
|
||||
xml += `
|
||||
</scan:ScanSettings>`;
|
||||
|
||||
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<IScanResult> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user