From b89e8cbc3c0b8f1b92d1364eea634c4330dad799 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 13 Jan 2026 21:14:30 +0000 Subject: [PATCH] feat(print): use IPP smartPrint and normalize IPP capabilities and job mapping --- changelog.md | 10 + ts/00_commitinfo_data.ts | 2 +- ts/features/feature.print.ts | 96 ++- ts/protocols/index.ts | 7 +- ts/protocols/protocol.ipp.ts | 1497 +++++++++++++++++++++++++++------- 5 files changed, 1279 insertions(+), 333 deletions(-) diff --git a/changelog.md b/changelog.md index 4be8a97..90fc815 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2026-01-13 - 3.1.0 - feat(print) +use IPP smartPrint and normalize IPP capabilities and job mapping + +- Use IppProtocol.smartPrint for automatic format detection/conversion when submitting print jobs. +- Normalize and map IIppJob -> IPrintJob via mapIppJobToInternal, collapsing extended IPP job states into internal states. +- Parse IIppPrinterCapabilities fields (mediaSizeSupported, mediaTypeSupported, sidesSupported, printQualitySupported, copiesSupported) and derive supportsDuplex from sidesSupported and maxCopies from copiesSupported range with a fallback. +- Map numeric IPP printQuality values (3,4,5) to internal quality strings (draft, normal, high). +- Switched calls to getPrinterAttributes/getJobAttributes and adjusted job listing to map returned IIppJob objects. +- Export new IPP types from protocols index: IIppPrinterCapabilities, IIppJob, IIppPrintOptions. + ## 2026-01-12 - 3.0.2 - fix(devicemanager) no changes detected - nothing to commit diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 7253179..9ad986b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@ecobridge.xyz/devicemanager', - version: '3.0.2', + version: '3.1.0', description: 'a device manager for talking to devices on network and over usb' } diff --git a/ts/features/feature.print.ts b/ts/features/feature.print.ts index 0c092b0..3d17a27 100644 --- a/ts/features/feature.print.ts +++ b/ts/features/feature.print.ts @@ -4,7 +4,7 @@ */ import { Feature, type TDeviceReference } from './feature.abstract.js'; -import { IppProtocol } from '../protocols/index.js'; +import { IppProtocol, type IIppPrinterCapabilities, type IIppJob } from '../protocols/index.js'; import type { TPrintProtocol, TPrintSides, @@ -16,7 +16,6 @@ import type { IPrintFeatureInfo, IFeatureOptions, } from '../interfaces/feature.interfaces.js'; -import type { IPrinterCapabilities } from '../interfaces/index.js'; /** * Options for creating a PrintFeature @@ -101,7 +100,7 @@ export class PrintFeature extends Feature { this.ippClient = new IppProtocol(address, port, path); // Verify connection by getting printer attributes - const attrs = await this.ippClient.getAttributes(); + const attrs = await this.ippClient.getPrinterAttributes(); this.updateCapabilitiesFromIpp(attrs); } // JetDirect and LPD don't need connection verification @@ -154,7 +153,8 @@ export class PrintFeature extends Feature { throw new Error('Print feature not connected'); } - return this.ippClient.getJobs(); + const jobs = await this.ippClient.getJobs(); + return jobs.map(job => this.mapIppJobToInternal(job)); } /** @@ -165,7 +165,8 @@ export class PrintFeature extends Feature { throw new Error('Print feature not connected'); } - return this.ippClient.getJobInfo(jobId); + const job = await this.ippClient.getJobAttributes(jobId); + return this.mapIppJobToInternal(job); } /** @@ -191,9 +192,17 @@ export class PrintFeature extends Feature { this.emit('print:started', options); - // IppProtocol.print() accepts IPrintOptions and returns IPrintJob - const job = await this.ippClient.print(data, 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; } @@ -202,58 +211,57 @@ export class PrintFeature extends Feature { // Helper Methods // ============================================================================ - private updateCapabilitiesFromIpp(caps: IPrinterCapabilities): void { + private updateCapabilitiesFromIpp(caps: IIppPrinterCapabilities): void { this.supportsColor = caps.colorSupported; - this.supportsDuplex = caps.duplexSupported; - this.maxCopies = caps.maxCopies; + // 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.mediaSizes && caps.mediaSizes.length > 0) { - this.supportedMediaSizes = caps.mediaSizes; + if (caps.mediaSizeSupported && caps.mediaSizeSupported.length > 0) { + this.supportedMediaSizes = caps.mediaSizeSupported; } - if (caps.mediaTypes && caps.mediaTypes.length > 0) { - this.supportedMediaTypes = caps.mediaTypes; + 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) ); } - if (caps.qualitySupported && caps.qualitySupported.length > 0) { - this.supportedQualities = caps.qualitySupported.filter((q): q is TPrintQuality => - ['draft', 'normal', 'high'].includes(q) - ); + // Map IPP quality values (3=draft, 4=normal, 5=high) to strings + if (caps.printQualitySupported && caps.printQualitySupported.length > 0) { + const qualityMap: Record = { 3: 'draft', 4: 'normal', 5: 'high' }; + this.supportedQualities = caps.printQualitySupported + .map(q => qualityMap[q]) + .filter((q): q is TPrintQuality => q !== undefined); } } - private qualityToIpp(quality: TPrintQuality): number { - switch (quality) { - case 'draft': return 3; - case 'normal': return 4; - case 'high': return 5; - default: return 4; - } - } - - private mapIppJob(job: Record): IPrintJob { - const stateMap: Record = { - 3: 'pending', - 4: 'pending', - 5: 'processing', - 6: 'processing', - 7: 'canceled', - 8: 'aborted', - 9: 'completed', + /** + * Map IIppJob to IPrintJob, normalizing extended states + */ + private mapIppJobToInternal(job: IIppJob): IPrintJob { + // Map extended IPP states to simpler internal states + const stateMap: Record = { + 'pending': 'pending', + 'pending-held': 'pending', + 'processing': 'processing', + 'processing-stopped': 'processing', + 'canceled': 'canceled', + 'aborted': 'aborted', + 'completed': 'completed', }; return { - id: job['job-id'] as number, - name: job['job-name'] as string ?? 'Unknown', - state: stateMap[(job['job-state'] as number) ?? 3] ?? 'pending', - stateReason: (job['job-state-reasons'] as string[])?.[0], - createdAt: new Date((job['time-at-creation'] as number) * 1000), - completedAt: job['time-at-completed'] - ? new Date((job['time-at-completed'] as number) * 1000) - : undefined, + id: job.id, + name: job.name, + state: stateMap[job.state], + stateReason: job.stateReasons?.[0], + createdAt: job.createdAt, + completedAt: job.completedAt, }; } diff --git a/ts/protocols/index.ts b/ts/protocols/index.ts index f9eaec7..1d0c262 100644 --- a/ts/protocols/index.ts +++ b/ts/protocols/index.ts @@ -10,7 +10,12 @@ export { EsclProtocol } from './protocol.escl.js'; export { SaneProtocol } from './protocol.sane.js'; // IPP printer protocol -export { IppProtocol } from './protocol.ipp.js'; +export { + IppProtocol, + type IIppPrinterCapabilities, + type IIppJob, + type IIppPrintOptions, +} from './protocol.ipp.js'; // SNMP query protocol export { diff --git a/ts/protocols/protocol.ipp.ts b/ts/protocols/protocol.ipp.ts index 1302928..5dac3ff 100644 --- a/ts/protocols/protocol.ipp.ts +++ b/ts/protocols/protocol.ipp.ts @@ -1,329 +1,1252 @@ -import * as plugins from '../plugins.js'; -import type { - IPrinterCapabilities, - IPrintOptions, - IPrintJob, -} from '../interfaces/index.js'; - /** - * IPP protocol wrapper using the ipp npm package + * 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. */ -export class IppProtocol { - private printerUrl: string; - private printer: ReturnType; - constructor(address: string, port: number, path: string = '/ipp/print') { - this.printerUrl = `ipp://${address}:${port}${path}`; - this.printer = plugins.ipp.Printer(this.printerUrl); +// ============================================================================ +// 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; } - /** - * Get printer attributes/capabilities - */ - public async getAttributes(): Promise { - return new Promise((resolve, reject) => { - this.printer.execute( - 'Get-Printer-Attributes', - null, - (err: Error | null, res: Record) => { - if (err) { - reject(err); - return; - } - - try { - const attrs = res['printer-attributes-tag'] as Record || {}; - resolve(this.parseCapabilities(attrs)); - } catch (parseErr) { - reject(parseErr); - } - } - ); - }); + private writeInt8(value: number): void { + this.buffer.push(value & 0xff); } - /** - * Print a document - */ - public async print(data: Buffer, options?: IPrintOptions): Promise { - const msg = this.buildPrintMessage(options); - - return new Promise((resolve, reject) => { - this.printer.execute( - 'Print-Job', - { ...msg, data }, - (err: Error | null, res: Record) => { - if (err) { - reject(err); - return; - } - - try { - const jobAttrs = res['job-attributes-tag'] as Record || {}; - resolve(this.parseJobInfo(jobAttrs)); - } catch (parseErr) { - reject(parseErr); - } - } - ); - }); + private writeInt16(value: number): void { + this.buffer.push((value >> 8) & 0xff); + this.buffer.push(value & 0xff); } - /** - * Get all jobs - */ - public async getJobs(): Promise { - return new Promise((resolve, reject) => { - this.printer.execute( - 'Get-Jobs', - { - 'operation-attributes-tag': { - 'requesting-user-name': 'devicemanager', - 'which-jobs': 'not-completed', - }, - }, - (err: Error | null, res: Record) => { - if (err) { - reject(err); - return; - } - - try { - const jobs: IPrintJob[] = []; - const jobTags = res['job-attributes-tag']; - - if (Array.isArray(jobTags)) { - for (const jobAttrs of jobTags) { - jobs.push(this.parseJobInfo(jobAttrs as Record)); - } - } else if (jobTags && typeof jobTags === 'object') { - jobs.push(this.parseJobInfo(jobTags as Record)); - } - - resolve(jobs); - } catch (parseErr) { - reject(parseErr); - } - } - ); - }); + 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); } - /** - * Get specific job info - */ - public async getJobInfo(jobId: number): Promise { - return new Promise((resolve, reject) => { - this.printer.execute( - 'Get-Job-Attributes', - { - 'operation-attributes-tag': { - 'job-id': jobId, - }, - }, - (err: Error | null, res: Record) => { - if (err) { - reject(err); - return; - } - - try { - const jobAttrs = res['job-attributes-tag'] as Record || {}; - resolve(this.parseJobInfo(jobAttrs)); - } catch (parseErr) { - reject(parseErr); - } - } - ); - }); - } - - /** - * Cancel a job - */ - public async cancelJob(jobId: number): Promise { - return new Promise((resolve, reject) => { - this.printer.execute( - 'Cancel-Job', - { - 'operation-attributes-tag': { - 'job-id': jobId, - }, - }, - (err: Error | null, _res: Record) => { - if (err) { - reject(err); - return; - } - resolve(); - } - ); - }); - } - - /** - * Check if printer is available - */ - public async checkAvailability(): Promise { - try { - await this.getAttributes(); - return true; - } catch { - return false; + 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); } } - /** - * Build IPP print message from options - */ - private buildPrintMessage(options?: IPrintOptions): Record { - const operationAttrs: Record = { - 'requesting-user-name': 'devicemanager', - 'job-name': options?.jobName ?? 'Print Job', - 'document-format': 'application/octet-stream', + 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, }; - const jobAttrs: Record = {}; - - if (options?.copies && options.copies > 1) { - jobAttrs['copies'] = options.copies; + if (knownTags[name]) { + return knownTags[name]; } - if (options?.mediaSize) { - jobAttrs['media'] = options.mediaSize; + // 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; - if (options?.mediaType) { - jobAttrs['media-type'] = options.mediaType; - } - - if (options?.sides) { - jobAttrs['sides'] = options.sides; - } - - if (options?.quality) { - const qualityMap: Record = { - draft: 3, - normal: 4, - high: 5, - }; - jobAttrs['print-quality'] = qualityMap[options.quality] ?? 4; - } - - if (options?.colorMode) { - jobAttrs['print-color-mode'] = options.colorMode; - } - - const msg: Record = { - 'operation-attributes-tag': operationAttrs, - }; - - if (Object.keys(jobAttrs).length > 0) { - msg['job-attributes-tag'] = jobAttrs; - } - - return msg; + return TAG_VALUE.NAME_WITHOUT_LANGUAGE; } - /** - * Parse printer capabilities from attributes - */ - private parseCapabilities(attrs: Record): IPrinterCapabilities { - const getArray = (key: string): string[] => { - const value = attrs[key]; - if (Array.isArray(value)) return value.map(String); - if (value !== undefined) return [String(value)]; - return []; + 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: {}, }; - const getNumber = (key: string, defaultVal: number): number => { - const value = attrs[key]; - if (typeof value === 'number') return value; - if (typeof value === 'string') return parseInt(value) || defaultVal; - return defaultVal; - }; + // Read attribute groups + while (this.offset < this.buffer.length) { + const tag = this.readInt8(); - const getBool = (key: string, defaultVal: boolean): boolean => { - const value = attrs[key]; - if (typeof value === 'boolean') return value; - if (value === 'true' || value === 1) return true; - if (value === 'false' || value === 0) return false; - return defaultVal; - }; + 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; + } - // Parse resolutions - const resolutions: number[] = []; - const resSupported = attrs['printer-resolution-supported']; - if (Array.isArray(resSupported)) { - for (const res of resSupported) { - if (typeof res === 'object' && res !== null && 'x' in res) { - resolutions.push((res as { x: number }).x); - } else if (typeof res === 'number') { - resolutions.push(res); + 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]; } } } - if (resolutions.length === 0) { - resolutions.push(300, 600); + + 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}`); } - return { - colorSupported: getBool('color-supported', false), - duplexSupported: - getArray('sides-supported').some((s) => - s.includes('two-sided') - ), - mediaSizes: getArray('media-supported'), - mediaTypes: getArray('media-type-supported'), - resolutions: [...new Set(resolutions)], - maxCopies: getNumber('copies-supported', 99), - sidesSupported: getArray('sides-supported'), - qualitySupported: getArray('print-quality-supported').map(String), + 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, + }); } /** - * Parse job info from attributes + * Detect document format from magic bytes */ - private parseJobInfo(attrs: Record): IPrintJob { - const getString = (key: string, defaultVal: string): string => { - const value = attrs[key]; - if (typeof value === 'string') return value; - if (value !== undefined) return String(value); - return defaultVal; - }; + private detectFormat(data: Buffer): string { + if (data.length < 4) { + return 'application/octet-stream'; + } - const getNumber = (key: string, defaultVal: number): number => { - const value = attrs[key]; - if (typeof value === 'number') return value; - if (typeof value === 'string') return parseInt(value) || defaultVal; - return defaultVal; - }; + // PDF: starts with %PDF + if (data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) { + return 'application/pdf'; + } - // Map IPP job state to our state - const ippState = getNumber('job-state', 3); - const stateMap: Record = { - 3: 'pending', // pending - 4: 'pending', // pending-held - 5: 'processing', // processing - 6: 'processing', // processing-stopped - 7: 'canceled', // canceled - 8: 'aborted', // aborted - 9: 'completed', // completed - }; + // JPEG: starts with FFD8FF + if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return 'image/jpeg'; + } - const createdTime = attrs['time-at-creation']; - const completedTime = attrs['time-at-completed']; + // PNG: starts with 89504E47 + if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { + return 'image/png'; + } - return { - id: getNumber('job-id', 0), - name: getString('job-name', 'Unknown Job'), - state: stateMap[ippState] ?? 'pending', - stateReason: getString('job-state-reasons', undefined), - createdAt: createdTime ? new Date(createdTime as number * 1000) : new Date(), - completedAt: completedTime ? new Date(completedTime as number * 1000) : undefined, - pagesPrinted: getNumber('job-media-sheets-completed', undefined), - pagesTotal: getNumber('job-media-sheets', undefined), - }; + // 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, +};