2026-01-09 07:14:39 +00:00
|
|
|
/**
|
2026-01-13 21:14:30 +00:00
|
|
|
* 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.
|
2026-01-09 07:14:39 +00:00
|
|
|
*/
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// 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<string, TIppValue>;
|
|
|
|
|
jobAttributes?: Record<string, TIppValue>;
|
|
|
|
|
printerAttributes?: Record<string, TIppValue>;
|
|
|
|
|
unsupportedAttributes?: Record<string, TIppValue>;
|
|
|
|
|
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;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
private writeInt8(value: number): void {
|
|
|
|
|
this.buffer.push(value & 0xff);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
private writeInt16(value: number): void {
|
|
|
|
|
this.buffer.push((value >> 8) & 0xff);
|
|
|
|
|
this.buffer.push(value & 0xff);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
private writeDelimiter(tag: number): void {
|
|
|
|
|
this.writeInt8(tag);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
private writeAttributes(attrs: Record<string, TIppValue>): void {
|
|
|
|
|
for (const [name, value] of Object.entries(attrs)) {
|
|
|
|
|
this.writeAttribute(name, value);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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<string, number> = {
|
|
|
|
|
// 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,
|
2026-01-09 07:14:39 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
if (knownTags[name]) {
|
|
|
|
|
return knownTags[name];
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
// 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;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
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;
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// IPP Message Decoder
|
|
|
|
|
// ============================================================================
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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();
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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<string, TIppValue> {
|
|
|
|
|
const attrs: Record<string, TIppValue> = {};
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-09 07:14:39 +00:00
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
// ============================================================================
|
|
|
|
|
// Status Code Helpers
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
function isSuccessful(statusCode: number): boolean {
|
|
|
|
|
return statusCode >= 0x0000 && statusCode <= 0x00ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusCodeToString(code: number): string {
|
|
|
|
|
const statusMap: Record<number, string> = {
|
|
|
|
|
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<IIppMessage> {
|
|
|
|
|
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}`);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
const responseData = Buffer.from(await response.arrayBuffer());
|
|
|
|
|
return this.decoder.decode(responseData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// Get Printer Attributes
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
public async getPrinterAttributes(): Promise<IIppPrinterCapabilities> {
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-01-09 07:14:39 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
const response = await this.request(message);
|
|
|
|
|
|
|
|
|
|
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
|
|
|
throw new Error(`IPP error: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
return this.parseCapabilities(response.printerAttributes || {});
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
private parseCapabilities(attrs: Record<string, TIppValue>): 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)];
|
2026-01-09 07:14:39 +00:00
|
|
|
return [];
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
const getNumber = (key: string, defaultVal: number = 0): number => {
|
|
|
|
|
const val = attrs[key];
|
|
|
|
|
if (typeof val === 'number') return val;
|
2026-01-09 07:14:39 +00:00
|
|
|
return defaultVal;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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;
|
2026-01-09 07:14:39 +00:00
|
|
|
return defaultVal;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
const getRange = (key: string): TIppRange => {
|
|
|
|
|
const val = attrs[key];
|
|
|
|
|
if (val && typeof val === 'object' && 'lower' in val) {
|
|
|
|
|
return val as TIppRange;
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
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';
|
2026-01-09 07:14:39 +00:00
|
|
|
|
|
|
|
|
return {
|
2026-01-13 21:14:30 +00:00
|
|
|
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'),
|
|
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
colorSupported: getBool('color-supported', false),
|
2026-01-13 21:14:30 +00:00
|
|
|
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'),
|
2026-01-09 07:14:39 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
// ==========================================================================
|
|
|
|
|
// Print Job
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
public async printJob(
|
|
|
|
|
data: Buffer,
|
|
|
|
|
options: IIppPrintOptions = {}
|
|
|
|
|
): Promise<IIppJob> {
|
|
|
|
|
// Build operation attributes
|
|
|
|
|
const operationAttrs: Record<string, TIppValue> = {
|
|
|
|
|
'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<string, TIppValue> = {};
|
|
|
|
|
|
|
|
|
|
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<IIppJob[]> {
|
|
|
|
|
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<IIppJob> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<boolean> {
|
|
|
|
|
const operationAttrs: Record<string, TIppValue> = {
|
|
|
|
|
'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<string, TIppValue>): 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];
|
2026-01-09 07:14:39 +00:00
|
|
|
return defaultVal;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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]);
|
2026-01-09 07:14:39 +00:00
|
|
|
return defaultVal;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
const getStringArray = (key: string): string[] => {
|
|
|
|
|
const val = attrs[key];
|
|
|
|
|
if (Array.isArray(val)) return val.map(String);
|
|
|
|
|
if (val !== undefined) return [String(val)];
|
|
|
|
|
return [];
|
2026-01-09 07:14:39 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 21:14:30 +00:00
|
|
|
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<number, IIppJob['state']> = {
|
|
|
|
|
[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',
|
|
|
|
|
};
|
2026-01-09 07:14:39 +00:00
|
|
|
|
|
|
|
|
return {
|
2026-01-13 21:14:30 +00:00
|
|
|
id: getNumber('job-id'),
|
|
|
|
|
uri: getString('job-uri'),
|
|
|
|
|
state: jobStateMap[jobState] || 'pending',
|
|
|
|
|
stateReasons: getStringArray('job-state-reasons'),
|
2026-01-09 07:14:39 +00:00
|
|
|
name: getString('job-name', 'Unknown Job'),
|
2026-01-13 21:14:30 +00:00
|
|
|
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<boolean> {
|
|
|
|
|
const caps = await this.getPrinterAttributes();
|
|
|
|
|
return caps.documentFormatSupported.includes(mimeType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
// Convenience: Get best format for data
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
|
|
|
|
public async getBestFormat(preferredFormats: string[]): Promise<string | null> {
|
|
|
|
|
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<IIppJob> {
|
|
|
|
|
// 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<Buffer> {
|
|
|
|
|
// 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<string, string> = {
|
|
|
|
|
'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',
|
2026-01-09 07:14:39 +00:00
|
|
|
};
|
2026-01-13 21:14:30 +00:00
|
|
|
return extensions[mimeType] || '.bin';
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
|
2026-01-09 07:14:39 +00:00
|
|
|
}
|
2026-01-13 21:14:30 +00:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
};
|