306 lines
9.7 KiB
TypeScript
306 lines
9.7 KiB
TypeScript
/**
|
|
* Print Feature
|
|
* Provides document printing capability using IPP protocol
|
|
*/
|
|
|
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
|
import { IppProtocol, type IIppPrinterCapabilities, type IIppJob } from '../protocols/index.js';
|
|
import type {
|
|
TPrintProtocol,
|
|
TPrintSides,
|
|
TPrintQuality,
|
|
TPrintColorMode,
|
|
IPrintCapabilities,
|
|
IPrintOptions,
|
|
IPrintJob,
|
|
IPrintFeatureInfo,
|
|
IFeatureOptions,
|
|
} from '../interfaces/feature.interfaces.js';
|
|
|
|
/**
|
|
* Options for creating a PrintFeature
|
|
*/
|
|
export interface IPrintFeatureOptions extends IFeatureOptions {
|
|
protocol?: TPrintProtocol;
|
|
uri?: string;
|
|
supportsColor?: boolean;
|
|
supportsDuplex?: boolean;
|
|
supportedMediaSizes?: string[];
|
|
supportedMediaTypes?: string[];
|
|
maxCopies?: number;
|
|
}
|
|
|
|
/**
|
|
* Print Feature - provides document printing capability
|
|
*
|
|
* Wraps the IPP protocol to provide a unified printing interface.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const printFeature = device.getFeature<PrintFeature>('print');
|
|
* if (printFeature) {
|
|
* await printFeature.connect();
|
|
* const job = await printFeature.print(pdfBuffer, { copies: 2 });
|
|
* console.log(`Print job ${job.id} created`);
|
|
* }
|
|
* ```
|
|
*/
|
|
export class PrintFeature extends Feature {
|
|
public readonly type = 'print' as const;
|
|
public readonly protocol: TPrintProtocol;
|
|
|
|
// Protocol client
|
|
private ippClient: IppProtocol | null = null;
|
|
|
|
// Configuration
|
|
public readonly uri: string;
|
|
|
|
// Capabilities
|
|
public supportsColor: boolean = true;
|
|
public supportsDuplex: boolean = false;
|
|
public supportedMediaSizes: string[] = ['iso_a4_210x297mm', 'na_letter_8.5x11in'];
|
|
public supportedMediaTypes: string[] = ['stationery'];
|
|
public maxCopies: number = 99;
|
|
public supportedSides: TPrintSides[] = ['one-sided'];
|
|
public supportedQualities: TPrintQuality[] = ['normal'];
|
|
|
|
constructor(
|
|
device: TDeviceReference,
|
|
port: number,
|
|
options?: IPrintFeatureOptions
|
|
) {
|
|
super(device, port, options);
|
|
this.protocol = options?.protocol ?? 'ipp';
|
|
this.uri = options?.uri ?? `ipp://${device.address}:${port}/ipp/print`;
|
|
|
|
// Set capabilities from options if provided
|
|
if (options?.supportsColor !== undefined) this.supportsColor = options.supportsColor;
|
|
if (options?.supportsDuplex !== undefined) {
|
|
this.supportsDuplex = options.supportsDuplex;
|
|
if (options.supportsDuplex) {
|
|
this.supportedSides = ['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'];
|
|
}
|
|
}
|
|
if (options?.supportedMediaSizes) this.supportedMediaSizes = options.supportedMediaSizes;
|
|
if (options?.supportedMediaTypes) this.supportedMediaTypes = options.supportedMediaTypes;
|
|
if (options?.maxCopies) this.maxCopies = options.maxCopies;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Connection
|
|
// ============================================================================
|
|
|
|
protected async doConnect(): Promise<void> {
|
|
if (this.protocol === 'ipp') {
|
|
// Parse URI to get address, port, and path
|
|
const url = new URL(this.uri.replace('ipp://', 'http://').replace('ipps://', 'https://'));
|
|
const address = url.hostname;
|
|
const port = parseInt(url.port) || this._port;
|
|
const path = url.pathname || '/ipp/print';
|
|
|
|
this.ippClient = new IppProtocol(address, port, path);
|
|
// Verify connection by getting printer attributes
|
|
const attrs = await this.ippClient.getPrinterAttributes();
|
|
this.updateCapabilitiesFromIpp(attrs);
|
|
}
|
|
// JetDirect and LPD don't need connection verification
|
|
}
|
|
|
|
protected async doDisconnect(): Promise<void> {
|
|
this.ippClient = null;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Printing Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get printer capabilities
|
|
*/
|
|
public async getCapabilities(): Promise<IPrintCapabilities> {
|
|
return {
|
|
colorSupported: this.supportsColor,
|
|
duplexSupported: this.supportsDuplex,
|
|
mediaSizes: this.supportedMediaSizes,
|
|
mediaTypes: this.supportedMediaTypes,
|
|
resolutions: [300, 600],
|
|
maxCopies: this.maxCopies,
|
|
sidesSupported: this.supportedSides,
|
|
qualitySupported: this.supportedQualities,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Print a document
|
|
*/
|
|
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
if (!this.isConnected) {
|
|
throw new Error('Print feature not connected');
|
|
}
|
|
|
|
if (this.protocol === 'ipp' && this.ippClient) {
|
|
return this.printWithIpp(data, options);
|
|
}
|
|
|
|
throw new Error(`Protocol ${this.protocol} not supported yet`);
|
|
}
|
|
|
|
/**
|
|
* Get active print jobs
|
|
*/
|
|
public async getJobs(): Promise<IPrintJob[]> {
|
|
if (!this.isConnected || !this.ippClient) {
|
|
throw new Error('Print feature not connected');
|
|
}
|
|
|
|
const jobs = await this.ippClient.getJobs();
|
|
return jobs.map(job => this.mapIppJobToInternal(job));
|
|
}
|
|
|
|
/**
|
|
* Get info about a specific job
|
|
*/
|
|
public async getJobInfo(jobId: number): Promise<IPrintJob> {
|
|
if (!this.isConnected || !this.ippClient) {
|
|
throw new Error('Print feature not connected');
|
|
}
|
|
|
|
const job = await this.ippClient.getJobAttributes(jobId);
|
|
return this.mapIppJobToInternal(job);
|
|
}
|
|
|
|
/**
|
|
* Cancel a print job
|
|
*/
|
|
public async cancelJob(jobId: number): Promise<void> {
|
|
if (!this.isConnected || !this.ippClient) {
|
|
throw new Error('Print feature not connected');
|
|
}
|
|
|
|
await this.ippClient.cancelJob(jobId);
|
|
this.emit('job:cancelled', jobId);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Protocol-Specific Printing
|
|
// ============================================================================
|
|
|
|
private async printWithIpp(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
if (!this.ippClient) {
|
|
throw new Error('IPP client not initialized');
|
|
}
|
|
|
|
this.emit('print:started', options);
|
|
|
|
// Use smartPrint for auto format detection and conversion
|
|
const ippJob = await this.ippClient.smartPrint(data, {
|
|
jobName: options?.jobName,
|
|
copies: options?.copies,
|
|
media: options?.mediaSize,
|
|
sides: options?.sides,
|
|
printQuality: options?.quality,
|
|
colorMode: options?.colorMode,
|
|
});
|
|
|
|
const job = this.mapIppJobToInternal(ippJob);
|
|
this.emit('print:submitted', job);
|
|
return job;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Methods
|
|
// ============================================================================
|
|
|
|
private updateCapabilitiesFromIpp(caps: IIppPrinterCapabilities): void {
|
|
this.supportsColor = caps.colorSupported;
|
|
// Derive duplexSupported from sidesSupported
|
|
this.supportsDuplex = caps.sidesSupported?.some(s =>
|
|
s.includes('two-sided')
|
|
) ?? false;
|
|
// Get max copies from range
|
|
this.maxCopies = caps.copiesSupported?.upper ?? 99;
|
|
|
|
if (caps.mediaSizeSupported && caps.mediaSizeSupported.length > 0) {
|
|
this.supportedMediaSizes = caps.mediaSizeSupported;
|
|
}
|
|
if (caps.mediaTypeSupported && caps.mediaTypeSupported.length > 0) {
|
|
this.supportedMediaTypes = caps.mediaTypeSupported;
|
|
}
|
|
if (caps.sidesSupported && caps.sidesSupported.length > 0) {
|
|
this.supportedSides = caps.sidesSupported.filter((s): s is TPrintSides =>
|
|
['one-sided', 'two-sided-long-edge', 'two-sided-short-edge'].includes(s)
|
|
);
|
|
}
|
|
// Map IPP quality values (3=draft, 4=normal, 5=high) to strings
|
|
if (caps.printQualitySupported && caps.printQualitySupported.length > 0) {
|
|
const qualityMap: Record<number, TPrintQuality> = { 3: 'draft', 4: 'normal', 5: 'high' };
|
|
this.supportedQualities = caps.printQualitySupported
|
|
.map(q => qualityMap[q])
|
|
.filter((q): q is TPrintQuality => q !== undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map IIppJob to IPrintJob, normalizing extended states
|
|
*/
|
|
private mapIppJobToInternal(job: IIppJob): IPrintJob {
|
|
// Map extended IPP states to simpler internal states
|
|
const stateMap: Record<IIppJob['state'], IPrintJob['state']> = {
|
|
'pending': 'pending',
|
|
'pending-held': 'pending',
|
|
'processing': 'processing',
|
|
'processing-stopped': 'processing',
|
|
'canceled': 'canceled',
|
|
'aborted': 'aborted',
|
|
'completed': 'completed',
|
|
};
|
|
|
|
return {
|
|
id: job.id,
|
|
name: job.name,
|
|
state: stateMap[job.state],
|
|
stateReason: job.stateReasons?.[0],
|
|
createdAt: job.createdAt,
|
|
completedAt: job.completedAt,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Serialization
|
|
// ============================================================================
|
|
|
|
public getFeatureInfo(): IPrintFeatureInfo {
|
|
return {
|
|
...this.getBaseFeatureInfo(),
|
|
type: 'print',
|
|
protocol: this.protocol,
|
|
supportsColor: this.supportsColor,
|
|
supportsDuplex: this.supportsDuplex,
|
|
supportedMediaSizes: this.supportedMediaSizes,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Static Factory
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Create from discovery metadata
|
|
*/
|
|
public static fromDiscovery(
|
|
device: TDeviceReference,
|
|
port: number,
|
|
protocol: TPrintProtocol,
|
|
metadata: Record<string, unknown>
|
|
): PrintFeature {
|
|
const txtRecords = metadata.txtRecords as Record<string, string> ?? {};
|
|
|
|
return new PrintFeature(device, port, {
|
|
protocol,
|
|
uri: metadata.uri as string,
|
|
supportsColor: txtRecords['Color'] === 'T' || txtRecords['color'] === 'true',
|
|
supportsDuplex: txtRecords['Duplex'] === 'T' || txtRecords['duplex'] === 'true',
|
|
});
|
|
}
|
|
}
|