/** * Clean IPP Protocol Implementation * RFC 8010 (Encoding) and RFC 8011 (Model) * * This is a from-scratch implementation with proper capability detection, * document format negotiation, and clean attribute parsing. */ // ============================================================================ // Constants // ============================================================================ /** IPP Version */ const IPP_VERSION = { V1_1: 0x0101, V2_0: 0x0200, } as const; /** IPP Operations */ const IPP_OPERATION = { PRINT_JOB: 0x0002, VALIDATE_JOB: 0x0004, CANCEL_JOB: 0x0008, GET_JOB_ATTRIBUTES: 0x0009, GET_JOBS: 0x000a, GET_PRINTER_ATTRIBUTES: 0x000b, } as const; /** IPP Status Codes */ const IPP_STATUS = { // Successful SUCCESSFUL_OK: 0x0000, SUCCESSFUL_OK_IGNORED_ATTRIBUTES: 0x0001, SUCCESSFUL_OK_CONFLICTING_ATTRIBUTES: 0x0002, // Client errors CLIENT_ERROR_BAD_REQUEST: 0x0400, CLIENT_ERROR_FORBIDDEN: 0x0401, CLIENT_ERROR_NOT_AUTHENTICATED: 0x0402, CLIENT_ERROR_NOT_AUTHORIZED: 0x0403, CLIENT_ERROR_NOT_POSSIBLE: 0x0404, CLIENT_ERROR_TIMEOUT: 0x0405, CLIENT_ERROR_NOT_FOUND: 0x0406, CLIENT_ERROR_DOCUMENT_FORMAT_NOT_SUPPORTED: 0x040a, // Server errors SERVER_ERROR_INTERNAL: 0x0500, SERVER_ERROR_OPERATION_NOT_SUPPORTED: 0x0501, SERVER_ERROR_SERVICE_UNAVAILABLE: 0x0502, SERVER_ERROR_DEVICE_ERROR: 0x0504, SERVER_ERROR_NOT_ACCEPTING_JOBS: 0x0506, SERVER_ERROR_BUSY: 0x0507, } as const; /** Delimiter Tags */ const TAG_DELIMITER = { OPERATION_ATTRIBUTES: 0x01, JOB_ATTRIBUTES: 0x02, END_OF_ATTRIBUTES: 0x03, PRINTER_ATTRIBUTES: 0x04, UNSUPPORTED_ATTRIBUTES: 0x05, } as const; /** Value Tags */ const TAG_VALUE = { // Out-of-band UNSUPPORTED: 0x10, UNKNOWN: 0x12, NO_VALUE: 0x13, // Integer INTEGER: 0x21, BOOLEAN: 0x22, ENUM: 0x23, // Octet string OCTET_STRING: 0x30, DATE_TIME: 0x31, RESOLUTION: 0x32, RANGE_OF_INTEGER: 0x33, BEG_COLLECTION: 0x34, TEXT_WITH_LANGUAGE: 0x35, NAME_WITH_LANGUAGE: 0x36, END_COLLECTION: 0x37, // Character string TEXT_WITHOUT_LANGUAGE: 0x41, NAME_WITHOUT_LANGUAGE: 0x42, KEYWORD: 0x44, URI: 0x45, URI_SCHEME: 0x46, CHARSET: 0x47, NATURAL_LANGUAGE: 0x48, MIME_MEDIA_TYPE: 0x49, MEMBER_ATTR_NAME: 0x4a, } as const; /** Job States */ const JOB_STATE = { PENDING: 3, PENDING_HELD: 4, PROCESSING: 5, PROCESSING_STOPPED: 6, CANCELED: 7, ABORTED: 8, COMPLETED: 9, } as const; /** Printer States */ const PRINTER_STATE = { IDLE: 3, PROCESSING: 4, STOPPED: 5, } as const; // ============================================================================ // Types // ============================================================================ type TIppValue = string | number | boolean | Date | TIppResolution | TIppRange | TIppValue[]; interface TIppResolution { crossFeed: number; feed: number; units: 'dpi' | 'dpcm'; } interface TIppRange { lower: number; upper: number; } interface IIppAttribute { tag: number; name: string; value: TIppValue; } interface IIppMessage { version: number; operationIdOrStatusCode: number; requestId: number; operationAttributes: Record; jobAttributes?: Record; printerAttributes?: Record; unsupportedAttributes?: Record; data?: Buffer; } export interface IIppPrinterCapabilities { // Basic info printerName: string; printerInfo?: string; printerMakeAndModel?: string; printerLocation?: string; printerUri: string; // State printerState: 'idle' | 'processing' | 'stopped'; printerStateReasons: string[]; printerIsAcceptingJobs: boolean; queuedJobCount: number; // Document formats documentFormatSupported: string[]; documentFormatDefault?: string; // Media mediaSizeSupported: string[]; mediaDefault?: string; mediaTypeSupported: string[]; // Capabilities colorSupported: boolean; sidesSupported: string[]; sidesDefault?: string; copiesSupported: TIppRange; printQualitySupported: number[]; resolutionsSupported: TIppResolution[]; // Additional operationsSupported: number[]; ippVersionsSupported: string[]; } export interface IIppPrintOptions { jobName?: string; requestingUserName?: string; documentFormat?: string; copies?: number; sides?: 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge'; media?: string; printQuality?: 'draft' | 'normal' | 'high'; colorMode?: 'monochrome' | 'color' | 'auto'; orientation?: 'portrait' | 'landscape' | 'reverse-landscape' | 'reverse-portrait'; } export interface IIppJob { id: number; uri: string; state: 'pending' | 'pending-held' | 'processing' | 'processing-stopped' | 'canceled' | 'aborted' | 'completed'; stateReasons: string[]; name: string; originatingUserName?: string; createdAt?: Date; completedAt?: Date; processingAt?: Date; impressionsCompleted?: number; } // ============================================================================ // IPP Message Encoder // ============================================================================ class IppEncoder { private buffer: number[] = []; public encode(message: IIppMessage): Buffer { this.buffer = []; // Version this.writeInt16(message.version); // Operation ID or Status Code this.writeInt16(message.operationIdOrStatusCode); // Request ID this.writeInt32(message.requestId); // Operation attributes (always required) this.writeDelimiter(TAG_DELIMITER.OPERATION_ATTRIBUTES); this.writeAttributes(message.operationAttributes); // Job attributes (optional) if (message.jobAttributes && Object.keys(message.jobAttributes).length > 0) { this.writeDelimiter(TAG_DELIMITER.JOB_ATTRIBUTES); this.writeAttributes(message.jobAttributes); } // End of attributes this.writeDelimiter(TAG_DELIMITER.END_OF_ATTRIBUTES); // Data (for Print-Job) const headerBuffer = Buffer.from(this.buffer); if (message.data) { return Buffer.concat([headerBuffer, message.data]); } return headerBuffer; } private writeInt8(value: number): void { this.buffer.push(value & 0xff); } private writeInt16(value: number): void { this.buffer.push((value >> 8) & 0xff); this.buffer.push(value & 0xff); } private writeInt32(value: number): void { this.buffer.push((value >> 24) & 0xff); this.buffer.push((value >> 16) & 0xff); this.buffer.push((value >> 8) & 0xff); this.buffer.push(value & 0xff); } private writeString(value: string): void { const bytes = Buffer.from(value, 'utf-8'); this.writeInt16(bytes.length); for (const byte of bytes) { this.buffer.push(byte); } } private writeDelimiter(tag: number): void { this.writeInt8(tag); } private writeAttributes(attrs: Record): void { for (const [name, value] of Object.entries(attrs)) { this.writeAttribute(name, value); } } private writeAttribute(name: string, value: TIppValue): void { if (Array.isArray(value)) { // Multi-value attribute for (let i = 0; i < value.length; i++) { this.writeSingleAttribute(i === 0 ? name : '', value[i], name); } } else { this.writeSingleAttribute(name, value, name); } } private writeSingleAttribute(name: string, value: TIppValue, originalName?: string): void { const tag = this.inferTag(originalName || name, value); this.writeInt8(tag); // Name const nameBytes = Buffer.from(name, 'utf-8'); this.writeInt16(nameBytes.length); for (const byte of nameBytes) { this.buffer.push(byte); } // Value this.writeValue(tag, value); } private inferTag(name: string, value: TIppValue): number { // IPP requires specific tags for certain attributes const knownTags: Record = { // Operation attributes 'attributes-charset': TAG_VALUE.CHARSET, 'attributes-natural-language': TAG_VALUE.NATURAL_LANGUAGE, 'printer-uri': TAG_VALUE.URI, 'job-uri': TAG_VALUE.URI, 'job-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE, 'requesting-user-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE, 'document-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE, 'document-format': TAG_VALUE.MIME_MEDIA_TYPE, // Job attributes - keywords 'media': TAG_VALUE.KEYWORD, 'media-type': TAG_VALUE.KEYWORD, 'sides': TAG_VALUE.KEYWORD, 'print-color-mode': TAG_VALUE.KEYWORD, 'output-bin': TAG_VALUE.KEYWORD, 'which-jobs': TAG_VALUE.KEYWORD, // Job attributes - enums (not integers!) 'print-quality': TAG_VALUE.ENUM, 'orientation-requested': TAG_VALUE.ENUM, 'finishings': TAG_VALUE.ENUM, // Job attributes - integers 'copies': TAG_VALUE.INTEGER, 'job-id': TAG_VALUE.INTEGER, 'job-priority': TAG_VALUE.INTEGER, }; if (knownTags[name]) { return knownTags[name]; } // Infer from value type if (typeof value === 'boolean') return TAG_VALUE.BOOLEAN; if (typeof value === 'number') return TAG_VALUE.INTEGER; if (value instanceof Date) return TAG_VALUE.DATE_TIME; if (typeof value === 'object' && 'crossFeed' in value) return TAG_VALUE.RESOLUTION; if (typeof value === 'object' && 'lower' in value) return TAG_VALUE.RANGE_OF_INTEGER; // String - try to infer type const str = String(value); if (str.startsWith('ipp://') || str.startsWith('ipps://') || str.startsWith('http://')) { return TAG_VALUE.URI; } if (str.includes('/')) return TAG_VALUE.MIME_MEDIA_TYPE; // e.g., application/pdf if (str.match(/^[a-z][a-z0-9-]*$/)) return TAG_VALUE.KEYWORD; return TAG_VALUE.NAME_WITHOUT_LANGUAGE; } private writeValue(tag: number, value: TIppValue): void { switch (tag) { case TAG_VALUE.INTEGER: case TAG_VALUE.ENUM: this.writeInt16(4); this.writeInt32(value as number); break; case TAG_VALUE.BOOLEAN: this.writeInt16(1); this.writeInt8(value ? 1 : 0); break; case TAG_VALUE.RESOLUTION: { const res = value as TIppResolution; this.writeInt16(9); this.writeInt32(res.crossFeed); this.writeInt32(res.feed); this.writeInt8(res.units === 'dpi' ? 3 : 4); break; } case TAG_VALUE.RANGE_OF_INTEGER: { const range = value as TIppRange; this.writeInt16(8); this.writeInt32(range.lower); this.writeInt32(range.upper); break; } case TAG_VALUE.DATE_TIME: { const date = value as Date; this.writeInt16(11); this.writeInt16(date.getUTCFullYear()); this.writeInt8(date.getUTCMonth() + 1); this.writeInt8(date.getUTCDate()); this.writeInt8(date.getUTCHours()); this.writeInt8(date.getUTCMinutes()); this.writeInt8(date.getUTCSeconds()); this.writeInt8(0); // deciseconds this.writeInt8('+'.charCodeAt(0)); this.writeInt8(0); // UTC offset hours this.writeInt8(0); // UTC offset minutes break; } default: // String types this.writeString(String(value)); break; } } } // ============================================================================ // IPP Message Decoder // ============================================================================ class IppDecoder { private buffer: Buffer = Buffer.alloc(0); private offset: number = 0; public decode(data: Buffer): IIppMessage { this.buffer = data; this.offset = 0; const version = this.readInt16(); const operationIdOrStatusCode = this.readInt16(); const requestId = this.readInt32(); const message: IIppMessage = { version, operationIdOrStatusCode, requestId, operationAttributes: {}, }; // Read attribute groups while (this.offset < this.buffer.length) { const tag = this.readInt8(); if (tag === TAG_DELIMITER.END_OF_ATTRIBUTES) { // Remaining data is document data if (this.offset < this.buffer.length) { message.data = this.buffer.subarray(this.offset); } break; } if (tag === TAG_DELIMITER.OPERATION_ATTRIBUTES) { message.operationAttributes = this.readAttributes(); } else if (tag === TAG_DELIMITER.JOB_ATTRIBUTES) { message.jobAttributes = this.readAttributes(); } else if (tag === TAG_DELIMITER.PRINTER_ATTRIBUTES) { message.printerAttributes = this.readAttributes(); } else if (tag === TAG_DELIMITER.UNSUPPORTED_ATTRIBUTES) { message.unsupportedAttributes = this.readAttributes(); } } return message; } private readInt8(): number { return this.buffer[this.offset++]; } private readInt16(): number { const value = this.buffer.readUInt16BE(this.offset); this.offset += 2; return value; } private readInt32(): number { const value = this.buffer.readInt32BE(this.offset); this.offset += 4; return value; } private readUInt32(): number { const value = this.buffer.readUInt32BE(this.offset); this.offset += 4; return value; } private readString(length: number): string { const str = this.buffer.toString('utf-8', this.offset, this.offset + length); this.offset += length; return str; } private readAttributes(): Record { const attrs: Record = {}; let currentName = ''; while (this.offset < this.buffer.length) { const tag = this.buffer[this.offset]; // Check for delimiter tag (start of new group or end) if (tag <= 0x0f) { break; } this.offset++; // Consume the tag const nameLength = this.readInt16(); const name = nameLength > 0 ? this.readString(nameLength) : currentName; const valueLength = this.readInt16(); const value = this.readValue(tag, valueLength); if (nameLength > 0) { currentName = name; attrs[name] = value; } else { // Additional value for same attribute - make it an array const existing = attrs[currentName]; if (Array.isArray(existing)) { existing.push(value); } else { attrs[currentName] = [existing, value]; } } } return attrs; } private readValue(tag: number, length: number): TIppValue { switch (tag) { case TAG_VALUE.INTEGER: case TAG_VALUE.ENUM: return this.readInt32(); case TAG_VALUE.BOOLEAN: return this.readInt8() !== 0; case TAG_VALUE.RESOLUTION: { const crossFeed = this.readInt32(); const feed = this.readInt32(); const units = this.readInt8() === 3 ? 'dpi' : 'dpcm'; return { crossFeed, feed, units } as TIppResolution; } case TAG_VALUE.RANGE_OF_INTEGER: { const lower = this.readInt32(); const upper = this.readInt32(); return { lower, upper } as TIppRange; } case TAG_VALUE.DATE_TIME: { const year = this.readInt16(); const month = this.readInt8(); const day = this.readInt8(); const hour = this.readInt8(); const minute = this.readInt8(); const second = this.readInt8(); this.readInt8(); // deciseconds const direction = String.fromCharCode(this.readInt8()); const offsetHours = this.readInt8(); const offsetMinutes = this.readInt8(); const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); const offsetMs = (offsetHours * 60 + offsetMinutes) * 60 * 1000; if (direction === '-') { date.setTime(date.getTime() + offsetMs); } else { date.setTime(date.getTime() - offsetMs); } return date; } case TAG_VALUE.NO_VALUE: case TAG_VALUE.UNKNOWN: case TAG_VALUE.UNSUPPORTED: this.offset += length; return ''; default: // String types return this.readString(length); } } } // ============================================================================ // Status Code Helpers // ============================================================================ function isSuccessful(statusCode: number): boolean { return statusCode >= 0x0000 && statusCode <= 0x00ff; } function statusCodeToString(code: number): string { const statusMap: Record = { 0x0000: 'successful-ok', 0x0001: 'successful-ok-ignored-or-substituted-attributes', 0x0002: 'successful-ok-conflicting-attributes', 0x0400: 'client-error-bad-request', 0x0401: 'client-error-forbidden', 0x0402: 'client-error-not-authenticated', 0x0403: 'client-error-not-authorized', 0x0404: 'client-error-not-possible', 0x0405: 'client-error-timeout', 0x0406: 'client-error-not-found', 0x040a: 'client-error-document-format-not-supported', 0x040b: 'client-error-attributes-or-values-not-supported', 0x0500: 'server-error-internal-error', 0x0501: 'server-error-operation-not-supported', 0x0502: 'server-error-service-unavailable', 0x0504: 'server-error-device-error', 0x0506: 'server-error-not-accepting-jobs', 0x0507: 'server-error-busy', }; return statusMap[code] || `unknown-status-${code.toString(16)}`; } // ============================================================================ // IPP Client // ============================================================================ export class IppClient { private readonly printerUri: string; private readonly httpUri: string; private requestId: number = 1; private readonly encoder = new IppEncoder(); private readonly decoder = new IppDecoder(); constructor( address: string, port: number = 631, path: string = '/ipp/print' ) { this.printerUri = `ipp://${address}:${port}${path}`; this.httpUri = `http://${address}:${port}${path}`; } // ========================================================================== // Core Request Method // ========================================================================== private async request(message: IIppMessage): Promise { const requestData = this.encoder.encode(message); const response = await fetch(this.httpUri, { method: 'POST', headers: { 'Content-Type': 'application/ipp', 'Accept': 'application/ipp', }, body: new Uint8Array(requestData), }); if (!response.ok) { throw new Error(`HTTP error: ${response.status} ${response.statusText}`); } const responseData = Buffer.from(await response.arrayBuffer()); return this.decoder.decode(responseData); } // ========================================================================== // Get Printer Attributes // ========================================================================== public async getPrinterAttributes(): Promise { const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.GET_PRINTER_ATTRIBUTES, requestId: this.requestId++, operationAttributes: { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, }, }; const response = await this.request(message); if (!isSuccessful(response.operationIdOrStatusCode)) { throw new Error(`IPP error: ${statusCodeToString(response.operationIdOrStatusCode)}`); } return this.parseCapabilities(response.printerAttributes || {}); } private parseCapabilities(attrs: Record): IIppPrinterCapabilities { const getString = (key: string, defaultVal: string = ''): string => { const val = attrs[key]; if (typeof val === 'string') return val; if (Array.isArray(val) && val.length > 0) return String(val[0]); return defaultVal; }; const getStringArray = (key: string): string[] => { const val = attrs[key]; if (Array.isArray(val)) return val.map(String); if (val !== undefined) return [String(val)]; return []; }; const getNumber = (key: string, defaultVal: number = 0): number => { const val = attrs[key]; if (typeof val === 'number') return val; return defaultVal; }; const getNumberArray = (key: string): number[] => { const val = attrs[key]; if (Array.isArray(val)) return val.filter((v): v is number => typeof v === 'number'); if (typeof val === 'number') return [val]; return []; }; const getBool = (key: string, defaultVal: boolean = false): boolean => { const val = attrs[key]; if (typeof val === 'boolean') return val; return defaultVal; }; const getRange = (key: string): TIppRange => { const val = attrs[key]; if (val && typeof val === 'object' && 'lower' in val) { return val as TIppRange; } return { lower: 1, upper: 1 }; }; const getResolutions = (): TIppResolution[] => { const val = attrs['printer-resolution-supported']; if (!val) return []; const values = Array.isArray(val) ? val : [val]; return values .filter((v): v is TIppResolution => typeof v === 'object' && v !== null && 'crossFeed' in v ); }; // Map printer state number to string const printerState = getNumber('printer-state', PRINTER_STATE.IDLE); const printerStateString = printerState === PRINTER_STATE.IDLE ? 'idle' : printerState === PRINTER_STATE.PROCESSING ? 'processing' : 'stopped'; return { printerName: getString('printer-name', 'Unknown Printer'), printerInfo: getString('printer-info') || undefined, printerMakeAndModel: getString('printer-make-and-model') || undefined, printerLocation: getString('printer-location') || undefined, printerUri: this.printerUri, printerState: printerStateString, printerStateReasons: getStringArray('printer-state-reasons'), printerIsAcceptingJobs: getBool('printer-is-accepting-jobs', true), queuedJobCount: getNumber('queued-job-count', 0), documentFormatSupported: getStringArray('document-format-supported'), documentFormatDefault: getString('document-format-default') || undefined, mediaSizeSupported: getStringArray('media-supported'), mediaDefault: getString('media-default') || undefined, mediaTypeSupported: getStringArray('media-type-supported'), colorSupported: getBool('color-supported', false), sidesSupported: getStringArray('sides-supported'), sidesDefault: getString('sides-default') || undefined, copiesSupported: getRange('copies-supported'), printQualitySupported: getNumberArray('print-quality-supported'), resolutionsSupported: getResolutions(), operationsSupported: getNumberArray('operations-supported'), ippVersionsSupported: getStringArray('ipp-versions-supported'), }; } // ========================================================================== // Print Job // ========================================================================== public async printJob( data: Buffer, options: IIppPrintOptions = {} ): Promise { // Build operation attributes const operationAttrs: Record = { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, 'requesting-user-name': options.requestingUserName || 'devicemanager', 'job-name': options.jobName || 'Print Job', 'document-format': options.documentFormat || 'application/octet-stream', }; // Build job attributes const jobAttrs: Record = {}; if (options.copies && options.copies > 1) { jobAttrs['copies'] = options.copies; } if (options.sides) { jobAttrs['sides'] = options.sides; } if (options.media) { jobAttrs['media'] = options.media; } if (options.printQuality) { const qualityMap = { draft: 3, normal: 4, high: 5 }; jobAttrs['print-quality'] = qualityMap[options.printQuality]; } if (options.colorMode) { jobAttrs['print-color-mode'] = options.colorMode; } if (options.orientation) { const orientationMap = { portrait: 3, landscape: 4, 'reverse-landscape': 5, 'reverse-portrait': 6, }; jobAttrs['orientation-requested'] = orientationMap[options.orientation]; } const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.PRINT_JOB, requestId: this.requestId++, operationAttributes: operationAttrs, jobAttributes: Object.keys(jobAttrs).length > 0 ? jobAttrs : undefined, data, }; const response = await this.request(message); if (!isSuccessful(response.operationIdOrStatusCode)) { throw new Error(`Print failed: ${statusCodeToString(response.operationIdOrStatusCode)}`); } return this.parseJob(response.jobAttributes || {}); } // ========================================================================== // Get Jobs // ========================================================================== public async getJobs(whichJobs: 'completed' | 'not-completed' | 'all' = 'not-completed'): Promise { const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.GET_JOBS, requestId: this.requestId++, operationAttributes: { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, 'requesting-user-name': 'devicemanager', 'which-jobs': whichJobs, }, }; const response = await this.request(message); if (!isSuccessful(response.operationIdOrStatusCode)) { throw new Error(`Get jobs failed: ${statusCodeToString(response.operationIdOrStatusCode)}`); } // Jobs can come back as a single job-attributes-tag or multiple // The decoder merges additional values into arrays if (!response.jobAttributes) { return []; } // If job-id is an array, we have multiple jobs encoded together // This is a limitation of the simple decoder - in reality each job is a separate group // For now, return single job if present return [this.parseJob(response.jobAttributes)]; } // ========================================================================== // Get Job Attributes // ========================================================================== public async getJobAttributes(jobId: number): Promise { const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.GET_JOB_ATTRIBUTES, requestId: this.requestId++, operationAttributes: { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, 'job-id': jobId, }, }; const response = await this.request(message); if (!isSuccessful(response.operationIdOrStatusCode)) { throw new Error(`Get job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`); } return this.parseJob(response.jobAttributes || {}); } // ========================================================================== // Cancel Job // ========================================================================== public async cancelJob(jobId: number): Promise { const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.CANCEL_JOB, requestId: this.requestId++, operationAttributes: { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, 'job-id': jobId, }, }; const response = await this.request(message); if (!isSuccessful(response.operationIdOrStatusCode)) { throw new Error(`Cancel job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`); } } // ========================================================================== // Validate Job (check if job would succeed without actually printing) // ========================================================================== public async validateJob(options: IIppPrintOptions = {}): Promise { const operationAttrs: Record = { 'attributes-charset': 'utf-8', 'attributes-natural-language': 'en-us', 'printer-uri': this.printerUri, 'document-format': options.documentFormat || 'application/octet-stream', }; const message: IIppMessage = { version: IPP_VERSION.V2_0, operationIdOrStatusCode: IPP_OPERATION.VALIDATE_JOB, requestId: this.requestId++, operationAttributes: operationAttrs, }; const response = await this.request(message); return isSuccessful(response.operationIdOrStatusCode); } // ========================================================================== // Helper Methods // ========================================================================== private parseJob(attrs: Record): IIppJob { const getNumber = (key: string, defaultVal: number = 0): number => { const val = attrs[key]; if (typeof val === 'number') return val; if (Array.isArray(val) && typeof val[0] === 'number') return val[0]; return defaultVal; }; const getString = (key: string, defaultVal: string = ''): string => { const val = attrs[key]; if (typeof val === 'string') return val; if (Array.isArray(val) && val.length > 0) return String(val[0]); return defaultVal; }; const getStringArray = (key: string): string[] => { const val = attrs[key]; if (Array.isArray(val)) return val.map(String); if (val !== undefined) return [String(val)]; return []; }; const getDate = (key: string): Date | undefined => { const val = attrs[key]; if (val instanceof Date) return val; return undefined; }; // Map job state number to string const jobState = getNumber('job-state', JOB_STATE.PENDING); const jobStateMap: Record = { [JOB_STATE.PENDING]: 'pending', [JOB_STATE.PENDING_HELD]: 'pending-held', [JOB_STATE.PROCESSING]: 'processing', [JOB_STATE.PROCESSING_STOPPED]: 'processing-stopped', [JOB_STATE.CANCELED]: 'canceled', [JOB_STATE.ABORTED]: 'aborted', [JOB_STATE.COMPLETED]: 'completed', }; return { id: getNumber('job-id'), uri: getString('job-uri'), state: jobStateMap[jobState] || 'pending', stateReasons: getStringArray('job-state-reasons'), name: getString('job-name', 'Unknown Job'), originatingUserName: getString('job-originating-user-name') || undefined, createdAt: getDate('time-at-creation') || getDate('date-time-at-creation'), completedAt: getDate('time-at-completed') || getDate('date-time-at-completed'), processingAt: getDate('time-at-processing') || getDate('date-time-at-processing'), impressionsCompleted: getNumber('job-impressions-completed') || undefined, }; } // ========================================================================== // Convenience: Check if format is supported // ========================================================================== public async isFormatSupported(mimeType: string): Promise { const caps = await this.getPrinterAttributes(); return caps.documentFormatSupported.includes(mimeType); } // ========================================================================== // Convenience: Get best format for data // ========================================================================== public async getBestFormat(preferredFormats: string[]): Promise { const caps = await this.getPrinterAttributes(); const supported = caps.documentFormatSupported; for (const format of preferredFormats) { if (supported.includes(format)) { return format; } } return null; } // ========================================================================== // Smart Print: Auto-detect format and convert if needed // ========================================================================== /** * Smart print that auto-detects document format and converts if necessary. * * If the document format is not supported by the printer, it will attempt * to convert to a supported format (e.g., PDF → JPEG). */ public async smartPrint( data: Buffer, options: IIppPrintOptions = {} ): Promise { // Detect format from data const detectedFormat = this.detectFormat(data); console.log(`[IPP] Detected format: ${detectedFormat}`); // Get printer capabilities const caps = await this.getPrinterAttributes(); const supported = caps.documentFormatSupported; // Check if detected format is supported if (supported.includes(detectedFormat)) { console.log(`[IPP] Format ${detectedFormat} is supported, printing directly`); return this.printJob(data, { ...options, documentFormat: detectedFormat, }); } // Format not supported - try to convert console.log(`[IPP] Format ${detectedFormat} not supported, attempting conversion`); // Determine best target format const conversionTargets = ['image/jpeg', 'image/png', 'image/pwg-raster']; const targetFormat = conversionTargets.find(f => supported.includes(f)); if (!targetFormat) { throw new Error( `Document format ${detectedFormat} not supported and no conversion target available. ` + `Printer supports: ${supported.join(', ')}` ); } // Convert the document const convertedData = await this.convertDocument(data, detectedFormat, targetFormat); console.log(`[IPP] Converted to ${targetFormat} (${convertedData.length} bytes)`); return this.printJob(convertedData, { ...options, documentFormat: targetFormat, }); } /** * Detect document format from magic bytes */ private detectFormat(data: Buffer): string { if (data.length < 4) { return 'application/octet-stream'; } // PDF: starts with %PDF if (data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) { return 'application/pdf'; } // JPEG: starts with FFD8FF if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { return 'image/jpeg'; } // PNG: starts with 89504E47 if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { return 'image/png'; } // TIFF: starts with 49492A00 (little endian) or 4D4D002A (big endian) if ((data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) || (data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)) { return 'image/tiff'; } // PostScript: starts with %! if (data[0] === 0x25 && data[1] === 0x21) { return 'application/postscript'; } // PWG Raster: starts with "RaS2" if (data[0] === 0x52 && data[1] === 0x61 && data[2] === 0x53 && data[3] === 0x32) { return 'image/pwg-raster'; } // URF (Apple Raster): starts with "UNIRAST" if (data.length >= 8 && data[0] === 0x55 && data[1] === 0x4e && data[2] === 0x49 && data[3] === 0x52 && data[4] === 0x41 && data[5] === 0x53 && data[6] === 0x54) { return 'image/urf'; } return 'application/octet-stream'; } /** * Convert document to target format * Uses ImageMagick if available, otherwise throws */ private async convertDocument( data: Buffer, sourceFormat: string, targetFormat: string ): Promise { // Try using ImageMagick via child_process const { execSync, spawnSync } = await import('child_process'); const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = await import('fs'); const { join } = await import('path'); const { tmpdir } = await import('os'); // Check if ImageMagick is available try { execSync('which convert', { stdio: 'ignore' }); } catch { throw new Error( `Cannot convert ${sourceFormat} to ${targetFormat}: ImageMagick not available` ); } // Create temp directory and files const tempDir = mkdtempSync(join(tmpdir(), 'ipp-convert-')); const sourceExt = this.formatToExtension(sourceFormat); const targetExt = this.formatToExtension(targetFormat); const sourcePath = join(tempDir, `input${sourceExt}`); const targetPath = join(tempDir, `output${targetExt}`); try { // Write source file writeFileSync(sourcePath, data); // Build convert command const args = [ sourcePath, '-density', '300', // Good DPI for print '-quality', '90', // JPEG quality '-background', 'white', // White background for transparency '-flatten', // Flatten layers targetPath, ]; // For multi-page PDFs, only convert first page if (sourceFormat === 'application/pdf') { args[0] = `${sourcePath}[0]`; // First page only } // Run conversion const result = spawnSync('convert', args, { timeout: 30000, maxBuffer: 100 * 1024 * 1024, // 100MB }); if (result.status !== 0) { const stderr = result.stderr?.toString() || 'Unknown error'; throw new Error(`ImageMagick conversion failed: ${stderr}`); } // Read converted file return readFileSync(targetPath); } finally { // Cleanup temp files try { unlinkSync(sourcePath); } catch {} try { unlinkSync(targetPath); } catch {} try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch {} } } /** * Get file extension for MIME type */ private formatToExtension(mimeType: string): string { const extensions: Record = { 'application/pdf': '.pdf', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/tiff': '.tiff', 'application/postscript': '.ps', 'image/pwg-raster': '.pwg', 'image/urf': '.urf', 'application/octet-stream': '.bin', }; return extensions[mimeType] || '.bin'; } } // Export as IppProtocol for consistency with other protocols export { IppClient as IppProtocol }; // ============================================================================ // Exports // ============================================================================ export { IPP_VERSION, IPP_OPERATION, IPP_STATUS, JOB_STATE, PRINTER_STATE, isSuccessful, statusCodeToString, };