@ecobridge.xyz/devicemanager 3.0.2 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitea/workflows/default_tags.yaml +0 -21
- package/.smartconfig.json +69 -0
- package/changelog.md +23 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/devicemanager.classes.devicemanager.js +33 -7
- package/dist_ts/discovery/discovery.classes.mdns.js +46 -11
- package/dist_ts/discovery/discovery.classes.networkscanner.d.ts +1 -1
- package/dist_ts/discovery/discovery.classes.networkscanner.js +1 -1
- package/dist_ts/discovery/discovery.classes.ssdp.d.ts +52 -21
- package/dist_ts/discovery/discovery.classes.ssdp.js +407 -117
- package/dist_ts/features/feature.print.d.ts +4 -2
- package/dist_ts/features/feature.print.js +48 -38
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/interfaces/feature.interfaces.d.ts +1 -1
- package/dist_ts/interfaces/index.d.ts +1 -1
- package/dist_ts/plugins.d.ts +5 -15
- package/dist_ts/plugins.js +6 -19
- package/dist_ts/protocols/index.d.ts +1 -1
- package/dist_ts/protocols/index.js +2 -2
- package/dist_ts/protocols/protocol.escl.js +2 -2
- package/dist_ts/protocols/protocol.ipp.d.ts +144 -33
- package/dist_ts/protocols/protocol.ipp.js +942 -239
- package/license.md +21 -0
- package/package.json +16 -25
- package/pnpm-workspace.yaml +4 -0
- package/readme.md +61 -76
- package/test/{test.ts → test.node.ts} +20 -45
- package/test/test.ssdp.node.ts +978 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/devicemanager.classes.devicemanager.ts +44 -6
- package/ts/discovery/discovery.classes.mdns.ts +40 -11
- package/ts/discovery/discovery.classes.networkscanner.ts +1 -1
- package/ts/discovery/discovery.classes.ssdp.ts +493 -130
- package/ts/features/feature.print.ts +53 -45
- package/ts/index.ts +1 -0
- package/ts/interfaces/feature.interfaces.ts +1 -1
- package/ts/interfaces/index.ts +1 -1
- package/ts/plugins.ts +5 -25
- package/ts/protocols/index.ts +6 -1
- package/ts/protocols/protocol.escl.ts +1 -1
- package/ts/protocols/protocol.ipp.ts +1183 -260
- package/npmextra.json +0 -24
|
@@ -1,329 +1,1252 @@
|
|
|
1
|
-
import * as plugins from '../plugins.js';
|
|
2
|
-
import type {
|
|
3
|
-
IPrinterCapabilities,
|
|
4
|
-
IPrintOptions,
|
|
5
|
-
IPrintJob,
|
|
6
|
-
} from '../interfaces/index.js';
|
|
7
|
-
|
|
8
1
|
/**
|
|
9
|
-
* IPP
|
|
2
|
+
* Clean IPP Protocol Implementation
|
|
3
|
+
* RFC 8010 (Encoding) and RFC 8011 (Model)
|
|
4
|
+
*
|
|
5
|
+
* This is a from-scratch implementation with proper capability detection,
|
|
6
|
+
* document format negotiation, and clean attribute parsing.
|
|
10
7
|
*/
|
|
11
|
-
export class IppProtocol {
|
|
12
|
-
private printerUrl: string;
|
|
13
|
-
private printer: ReturnType<typeof plugins.ipp.Printer>;
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Constants
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
/** IPP Version */
|
|
14
|
+
const IPP_VERSION = {
|
|
15
|
+
V1_1: 0x0101,
|
|
16
|
+
V2_0: 0x0200,
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** IPP Operations */
|
|
20
|
+
const IPP_OPERATION = {
|
|
21
|
+
PRINT_JOB: 0x0002,
|
|
22
|
+
VALIDATE_JOB: 0x0004,
|
|
23
|
+
CANCEL_JOB: 0x0008,
|
|
24
|
+
GET_JOB_ATTRIBUTES: 0x0009,
|
|
25
|
+
GET_JOBS: 0x000a,
|
|
26
|
+
GET_PRINTER_ATTRIBUTES: 0x000b,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** IPP Status Codes */
|
|
30
|
+
const IPP_STATUS = {
|
|
31
|
+
// Successful
|
|
32
|
+
SUCCESSFUL_OK: 0x0000,
|
|
33
|
+
SUCCESSFUL_OK_IGNORED_ATTRIBUTES: 0x0001,
|
|
34
|
+
SUCCESSFUL_OK_CONFLICTING_ATTRIBUTES: 0x0002,
|
|
35
|
+
|
|
36
|
+
// Client errors
|
|
37
|
+
CLIENT_ERROR_BAD_REQUEST: 0x0400,
|
|
38
|
+
CLIENT_ERROR_FORBIDDEN: 0x0401,
|
|
39
|
+
CLIENT_ERROR_NOT_AUTHENTICATED: 0x0402,
|
|
40
|
+
CLIENT_ERROR_NOT_AUTHORIZED: 0x0403,
|
|
41
|
+
CLIENT_ERROR_NOT_POSSIBLE: 0x0404,
|
|
42
|
+
CLIENT_ERROR_TIMEOUT: 0x0405,
|
|
43
|
+
CLIENT_ERROR_NOT_FOUND: 0x0406,
|
|
44
|
+
CLIENT_ERROR_DOCUMENT_FORMAT_NOT_SUPPORTED: 0x040a,
|
|
45
|
+
|
|
46
|
+
// Server errors
|
|
47
|
+
SERVER_ERROR_INTERNAL: 0x0500,
|
|
48
|
+
SERVER_ERROR_OPERATION_NOT_SUPPORTED: 0x0501,
|
|
49
|
+
SERVER_ERROR_SERVICE_UNAVAILABLE: 0x0502,
|
|
50
|
+
SERVER_ERROR_DEVICE_ERROR: 0x0504,
|
|
51
|
+
SERVER_ERROR_NOT_ACCEPTING_JOBS: 0x0506,
|
|
52
|
+
SERVER_ERROR_BUSY: 0x0507,
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** Delimiter Tags */
|
|
56
|
+
const TAG_DELIMITER = {
|
|
57
|
+
OPERATION_ATTRIBUTES: 0x01,
|
|
58
|
+
JOB_ATTRIBUTES: 0x02,
|
|
59
|
+
END_OF_ATTRIBUTES: 0x03,
|
|
60
|
+
PRINTER_ATTRIBUTES: 0x04,
|
|
61
|
+
UNSUPPORTED_ATTRIBUTES: 0x05,
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
/** Value Tags */
|
|
65
|
+
const TAG_VALUE = {
|
|
66
|
+
// Out-of-band
|
|
67
|
+
UNSUPPORTED: 0x10,
|
|
68
|
+
UNKNOWN: 0x12,
|
|
69
|
+
NO_VALUE: 0x13,
|
|
70
|
+
|
|
71
|
+
// Integer
|
|
72
|
+
INTEGER: 0x21,
|
|
73
|
+
BOOLEAN: 0x22,
|
|
74
|
+
ENUM: 0x23,
|
|
75
|
+
|
|
76
|
+
// Octet string
|
|
77
|
+
OCTET_STRING: 0x30,
|
|
78
|
+
DATE_TIME: 0x31,
|
|
79
|
+
RESOLUTION: 0x32,
|
|
80
|
+
RANGE_OF_INTEGER: 0x33,
|
|
81
|
+
BEG_COLLECTION: 0x34,
|
|
82
|
+
TEXT_WITH_LANGUAGE: 0x35,
|
|
83
|
+
NAME_WITH_LANGUAGE: 0x36,
|
|
84
|
+
END_COLLECTION: 0x37,
|
|
85
|
+
|
|
86
|
+
// Character string
|
|
87
|
+
TEXT_WITHOUT_LANGUAGE: 0x41,
|
|
88
|
+
NAME_WITHOUT_LANGUAGE: 0x42,
|
|
89
|
+
KEYWORD: 0x44,
|
|
90
|
+
URI: 0x45,
|
|
91
|
+
URI_SCHEME: 0x46,
|
|
92
|
+
CHARSET: 0x47,
|
|
93
|
+
NATURAL_LANGUAGE: 0x48,
|
|
94
|
+
MIME_MEDIA_TYPE: 0x49,
|
|
95
|
+
MEMBER_ATTR_NAME: 0x4a,
|
|
96
|
+
} as const;
|
|
97
|
+
|
|
98
|
+
/** Job States */
|
|
99
|
+
const JOB_STATE = {
|
|
100
|
+
PENDING: 3,
|
|
101
|
+
PENDING_HELD: 4,
|
|
102
|
+
PROCESSING: 5,
|
|
103
|
+
PROCESSING_STOPPED: 6,
|
|
104
|
+
CANCELED: 7,
|
|
105
|
+
ABORTED: 8,
|
|
106
|
+
COMPLETED: 9,
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
/** Printer States */
|
|
110
|
+
const PRINTER_STATE = {
|
|
111
|
+
IDLE: 3,
|
|
112
|
+
PROCESSING: 4,
|
|
113
|
+
STOPPED: 5,
|
|
114
|
+
} as const;
|
|
115
|
+
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// Types
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
type TIppValue = string | number | boolean | Date | TIppResolution | TIppRange | TIppValue[];
|
|
121
|
+
|
|
122
|
+
interface TIppResolution {
|
|
123
|
+
crossFeed: number;
|
|
124
|
+
feed: number;
|
|
125
|
+
units: 'dpi' | 'dpcm';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface TIppRange {
|
|
129
|
+
lower: number;
|
|
130
|
+
upper: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface IIppAttribute {
|
|
134
|
+
tag: number;
|
|
135
|
+
name: string;
|
|
136
|
+
value: TIppValue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface IIppMessage {
|
|
140
|
+
version: number;
|
|
141
|
+
operationIdOrStatusCode: number;
|
|
142
|
+
requestId: number;
|
|
143
|
+
operationAttributes: Record<string, TIppValue>;
|
|
144
|
+
jobAttributes?: Record<string, TIppValue>;
|
|
145
|
+
printerAttributes?: Record<string, TIppValue>;
|
|
146
|
+
unsupportedAttributes?: Record<string, TIppValue>;
|
|
147
|
+
data?: Buffer;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface IIppPrinterCapabilities {
|
|
151
|
+
// Basic info
|
|
152
|
+
printerName: string;
|
|
153
|
+
printerInfo?: string;
|
|
154
|
+
printerMakeAndModel?: string;
|
|
155
|
+
printerLocation?: string;
|
|
156
|
+
printerUri: string;
|
|
157
|
+
|
|
158
|
+
// State
|
|
159
|
+
printerState: 'idle' | 'processing' | 'stopped';
|
|
160
|
+
printerStateReasons: string[];
|
|
161
|
+
printerIsAcceptingJobs: boolean;
|
|
162
|
+
queuedJobCount: number;
|
|
163
|
+
|
|
164
|
+
// Document formats
|
|
165
|
+
documentFormatSupported: string[];
|
|
166
|
+
documentFormatDefault?: string;
|
|
167
|
+
|
|
168
|
+
// Media
|
|
169
|
+
mediaSizeSupported: string[];
|
|
170
|
+
mediaDefault?: string;
|
|
171
|
+
mediaTypeSupported: string[];
|
|
172
|
+
|
|
173
|
+
// Capabilities
|
|
174
|
+
colorSupported: boolean;
|
|
175
|
+
sidesSupported: string[];
|
|
176
|
+
sidesDefault?: string;
|
|
177
|
+
copiesSupported: TIppRange;
|
|
178
|
+
printQualitySupported: number[];
|
|
179
|
+
resolutionsSupported: TIppResolution[];
|
|
180
|
+
|
|
181
|
+
// Additional
|
|
182
|
+
operationsSupported: number[];
|
|
183
|
+
ippVersionsSupported: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface IIppPrintOptions {
|
|
187
|
+
jobName?: string;
|
|
188
|
+
requestingUserName?: string;
|
|
189
|
+
documentFormat?: string;
|
|
190
|
+
copies?: number;
|
|
191
|
+
sides?: 'one-sided' | 'two-sided-long-edge' | 'two-sided-short-edge';
|
|
192
|
+
media?: string;
|
|
193
|
+
printQuality?: 'draft' | 'normal' | 'high';
|
|
194
|
+
colorMode?: 'monochrome' | 'color' | 'auto';
|
|
195
|
+
orientation?: 'portrait' | 'landscape' | 'reverse-landscape' | 'reverse-portrait';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface IIppJob {
|
|
199
|
+
id: number;
|
|
200
|
+
uri: string;
|
|
201
|
+
state: 'pending' | 'pending-held' | 'processing' | 'processing-stopped' | 'canceled' | 'aborted' | 'completed';
|
|
202
|
+
stateReasons: string[];
|
|
203
|
+
name: string;
|
|
204
|
+
originatingUserName?: string;
|
|
205
|
+
createdAt?: Date;
|
|
206
|
+
completedAt?: Date;
|
|
207
|
+
processingAt?: Date;
|
|
208
|
+
impressionsCompleted?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// IPP Message Encoder
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
class IppEncoder {
|
|
216
|
+
private buffer: number[] = [];
|
|
217
|
+
|
|
218
|
+
public encode(message: IIppMessage): Buffer {
|
|
219
|
+
this.buffer = [];
|
|
220
|
+
|
|
221
|
+
// Version
|
|
222
|
+
this.writeInt16(message.version);
|
|
223
|
+
|
|
224
|
+
// Operation ID or Status Code
|
|
225
|
+
this.writeInt16(message.operationIdOrStatusCode);
|
|
226
|
+
|
|
227
|
+
// Request ID
|
|
228
|
+
this.writeInt32(message.requestId);
|
|
229
|
+
|
|
230
|
+
// Operation attributes (always required)
|
|
231
|
+
this.writeDelimiter(TAG_DELIMITER.OPERATION_ATTRIBUTES);
|
|
232
|
+
this.writeAttributes(message.operationAttributes);
|
|
233
|
+
|
|
234
|
+
// Job attributes (optional)
|
|
235
|
+
if (message.jobAttributes && Object.keys(message.jobAttributes).length > 0) {
|
|
236
|
+
this.writeDelimiter(TAG_DELIMITER.JOB_ATTRIBUTES);
|
|
237
|
+
this.writeAttributes(message.jobAttributes);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// End of attributes
|
|
241
|
+
this.writeDelimiter(TAG_DELIMITER.END_OF_ATTRIBUTES);
|
|
242
|
+
|
|
243
|
+
// Data (for Print-Job)
|
|
244
|
+
const headerBuffer = Buffer.from(this.buffer);
|
|
245
|
+
|
|
246
|
+
if (message.data) {
|
|
247
|
+
return Buffer.concat([headerBuffer, message.data]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return headerBuffer;
|
|
18
251
|
}
|
|
19
252
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*/
|
|
23
|
-
public async getAttributes(): Promise<IPrinterCapabilities> {
|
|
24
|
-
return new Promise((resolve, reject) => {
|
|
25
|
-
this.printer.execute(
|
|
26
|
-
'Get-Printer-Attributes',
|
|
27
|
-
null,
|
|
28
|
-
(err: Error | null, res: Record<string, unknown>) => {
|
|
29
|
-
if (err) {
|
|
30
|
-
reject(err);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const attrs = res['printer-attributes-tag'] as Record<string, unknown> || {};
|
|
36
|
-
resolve(this.parseCapabilities(attrs));
|
|
37
|
-
} catch (parseErr) {
|
|
38
|
-
reject(parseErr);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
);
|
|
42
|
-
});
|
|
253
|
+
private writeInt8(value: number): void {
|
|
254
|
+
this.buffer.push(value & 0xff);
|
|
43
255
|
}
|
|
44
256
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
public async print(data: Buffer, options?: IPrintOptions): Promise<IPrintJob> {
|
|
49
|
-
const msg = this.buildPrintMessage(options);
|
|
50
|
-
|
|
51
|
-
return new Promise((resolve, reject) => {
|
|
52
|
-
this.printer.execute(
|
|
53
|
-
'Print-Job',
|
|
54
|
-
{ ...msg, data },
|
|
55
|
-
(err: Error | null, res: Record<string, unknown>) => {
|
|
56
|
-
if (err) {
|
|
57
|
-
reject(err);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
|
63
|
-
resolve(this.parseJobInfo(jobAttrs));
|
|
64
|
-
} catch (parseErr) {
|
|
65
|
-
reject(parseErr);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
);
|
|
69
|
-
});
|
|
257
|
+
private writeInt16(value: number): void {
|
|
258
|
+
this.buffer.push((value >> 8) & 0xff);
|
|
259
|
+
this.buffer.push(value & 0xff);
|
|
70
260
|
}
|
|
71
261
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.printer.execute(
|
|
78
|
-
'Get-Jobs',
|
|
79
|
-
{
|
|
80
|
-
'operation-attributes-tag': {
|
|
81
|
-
'requesting-user-name': 'devicemanager',
|
|
82
|
-
'which-jobs': 'not-completed',
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
(err: Error | null, res: Record<string, unknown>) => {
|
|
86
|
-
if (err) {
|
|
87
|
-
reject(err);
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const jobs: IPrintJob[] = [];
|
|
93
|
-
const jobTags = res['job-attributes-tag'];
|
|
94
|
-
|
|
95
|
-
if (Array.isArray(jobTags)) {
|
|
96
|
-
for (const jobAttrs of jobTags) {
|
|
97
|
-
jobs.push(this.parseJobInfo(jobAttrs as Record<string, unknown>));
|
|
98
|
-
}
|
|
99
|
-
} else if (jobTags && typeof jobTags === 'object') {
|
|
100
|
-
jobs.push(this.parseJobInfo(jobTags as Record<string, unknown>));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
resolve(jobs);
|
|
104
|
-
} catch (parseErr) {
|
|
105
|
-
reject(parseErr);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
);
|
|
109
|
-
});
|
|
262
|
+
private writeInt32(value: number): void {
|
|
263
|
+
this.buffer.push((value >> 24) & 0xff);
|
|
264
|
+
this.buffer.push((value >> 16) & 0xff);
|
|
265
|
+
this.buffer.push((value >> 8) & 0xff);
|
|
266
|
+
this.buffer.push(value & 0xff);
|
|
110
267
|
}
|
|
111
268
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
'Get-Job-Attributes',
|
|
119
|
-
{
|
|
120
|
-
'operation-attributes-tag': {
|
|
121
|
-
'job-id': jobId,
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
(err: Error | null, res: Record<string, unknown>) => {
|
|
125
|
-
if (err) {
|
|
126
|
-
reject(err);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const jobAttrs = res['job-attributes-tag'] as Record<string, unknown> || {};
|
|
132
|
-
resolve(this.parseJobInfo(jobAttrs));
|
|
133
|
-
} catch (parseErr) {
|
|
134
|
-
reject(parseErr);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
);
|
|
138
|
-
});
|
|
269
|
+
private writeString(value: string): void {
|
|
270
|
+
const bytes = Buffer.from(value, 'utf-8');
|
|
271
|
+
this.writeInt16(bytes.length);
|
|
272
|
+
for (const byte of bytes) {
|
|
273
|
+
this.buffer.push(byte);
|
|
274
|
+
}
|
|
139
275
|
}
|
|
140
276
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*/
|
|
144
|
-
public async cancelJob(jobId: number): Promise<void> {
|
|
145
|
-
return new Promise((resolve, reject) => {
|
|
146
|
-
this.printer.execute(
|
|
147
|
-
'Cancel-Job',
|
|
148
|
-
{
|
|
149
|
-
'operation-attributes-tag': {
|
|
150
|
-
'job-id': jobId,
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
(err: Error | null, _res: Record<string, unknown>) => {
|
|
154
|
-
if (err) {
|
|
155
|
-
reject(err);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
resolve();
|
|
159
|
-
}
|
|
160
|
-
);
|
|
161
|
-
});
|
|
277
|
+
private writeDelimiter(tag: number): void {
|
|
278
|
+
this.writeInt8(tag);
|
|
162
279
|
}
|
|
163
280
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
public async checkAvailability(): Promise<boolean> {
|
|
168
|
-
try {
|
|
169
|
-
await this.getAttributes();
|
|
170
|
-
return true;
|
|
171
|
-
} catch {
|
|
172
|
-
return false;
|
|
281
|
+
private writeAttributes(attrs: Record<string, TIppValue>): void {
|
|
282
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
283
|
+
this.writeAttribute(name, value);
|
|
173
284
|
}
|
|
174
285
|
}
|
|
175
286
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
287
|
+
private writeAttribute(name: string, value: TIppValue): void {
|
|
288
|
+
if (Array.isArray(value)) {
|
|
289
|
+
// Multi-value attribute
|
|
290
|
+
for (let i = 0; i < value.length; i++) {
|
|
291
|
+
this.writeSingleAttribute(i === 0 ? name : '', value[i], name);
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
this.writeSingleAttribute(name, value, name);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private writeSingleAttribute(name: string, value: TIppValue, originalName?: string): void {
|
|
299
|
+
const tag = this.inferTag(originalName || name, value);
|
|
300
|
+
this.writeInt8(tag);
|
|
301
|
+
|
|
302
|
+
// Name
|
|
303
|
+
const nameBytes = Buffer.from(name, 'utf-8');
|
|
304
|
+
this.writeInt16(nameBytes.length);
|
|
305
|
+
for (const byte of nameBytes) {
|
|
306
|
+
this.buffer.push(byte);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Value
|
|
310
|
+
this.writeValue(tag, value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private inferTag(name: string, value: TIppValue): number {
|
|
314
|
+
// IPP requires specific tags for certain attributes
|
|
315
|
+
const knownTags: Record<string, number> = {
|
|
316
|
+
// Operation attributes
|
|
317
|
+
'attributes-charset': TAG_VALUE.CHARSET,
|
|
318
|
+
'attributes-natural-language': TAG_VALUE.NATURAL_LANGUAGE,
|
|
319
|
+
'printer-uri': TAG_VALUE.URI,
|
|
320
|
+
'job-uri': TAG_VALUE.URI,
|
|
321
|
+
'job-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
|
|
322
|
+
'requesting-user-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
|
|
323
|
+
'document-name': TAG_VALUE.NAME_WITHOUT_LANGUAGE,
|
|
324
|
+
'document-format': TAG_VALUE.MIME_MEDIA_TYPE,
|
|
325
|
+
// Job attributes - keywords
|
|
326
|
+
'media': TAG_VALUE.KEYWORD,
|
|
327
|
+
'media-type': TAG_VALUE.KEYWORD,
|
|
328
|
+
'sides': TAG_VALUE.KEYWORD,
|
|
329
|
+
'print-color-mode': TAG_VALUE.KEYWORD,
|
|
330
|
+
'output-bin': TAG_VALUE.KEYWORD,
|
|
331
|
+
'which-jobs': TAG_VALUE.KEYWORD,
|
|
332
|
+
// Job attributes - enums (not integers!)
|
|
333
|
+
'print-quality': TAG_VALUE.ENUM,
|
|
334
|
+
'orientation-requested': TAG_VALUE.ENUM,
|
|
335
|
+
'finishings': TAG_VALUE.ENUM,
|
|
336
|
+
// Job attributes - integers
|
|
337
|
+
'copies': TAG_VALUE.INTEGER,
|
|
338
|
+
'job-id': TAG_VALUE.INTEGER,
|
|
339
|
+
'job-priority': TAG_VALUE.INTEGER,
|
|
184
340
|
};
|
|
185
341
|
|
|
186
|
-
|
|
342
|
+
if (knownTags[name]) {
|
|
343
|
+
return knownTags[name];
|
|
344
|
+
}
|
|
187
345
|
|
|
188
|
-
|
|
189
|
-
|
|
346
|
+
// Infer from value type
|
|
347
|
+
if (typeof value === 'boolean') return TAG_VALUE.BOOLEAN;
|
|
348
|
+
if (typeof value === 'number') return TAG_VALUE.INTEGER;
|
|
349
|
+
if (value instanceof Date) return TAG_VALUE.DATE_TIME;
|
|
350
|
+
if (typeof value === 'object' && 'crossFeed' in value) return TAG_VALUE.RESOLUTION;
|
|
351
|
+
if (typeof value === 'object' && 'lower' in value) return TAG_VALUE.RANGE_OF_INTEGER;
|
|
352
|
+
|
|
353
|
+
// String - try to infer type
|
|
354
|
+
const str = String(value);
|
|
355
|
+
if (str.startsWith('ipp://') || str.startsWith('ipps://') || str.startsWith('http://')) {
|
|
356
|
+
return TAG_VALUE.URI;
|
|
190
357
|
}
|
|
358
|
+
if (str.includes('/')) return TAG_VALUE.MIME_MEDIA_TYPE; // e.g., application/pdf
|
|
359
|
+
if (str.match(/^[a-z][a-z0-9-]*$/)) return TAG_VALUE.KEYWORD;
|
|
360
|
+
|
|
361
|
+
return TAG_VALUE.NAME_WITHOUT_LANGUAGE;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private writeValue(tag: number, value: TIppValue): void {
|
|
365
|
+
switch (tag) {
|
|
366
|
+
case TAG_VALUE.INTEGER:
|
|
367
|
+
case TAG_VALUE.ENUM:
|
|
368
|
+
this.writeInt16(4);
|
|
369
|
+
this.writeInt32(value as number);
|
|
370
|
+
break;
|
|
191
371
|
|
|
192
|
-
|
|
193
|
-
|
|
372
|
+
case TAG_VALUE.BOOLEAN:
|
|
373
|
+
this.writeInt16(1);
|
|
374
|
+
this.writeInt8(value ? 1 : 0);
|
|
375
|
+
break;
|
|
376
|
+
|
|
377
|
+
case TAG_VALUE.RESOLUTION: {
|
|
378
|
+
const res = value as TIppResolution;
|
|
379
|
+
this.writeInt16(9);
|
|
380
|
+
this.writeInt32(res.crossFeed);
|
|
381
|
+
this.writeInt32(res.feed);
|
|
382
|
+
this.writeInt8(res.units === 'dpi' ? 3 : 4);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case TAG_VALUE.RANGE_OF_INTEGER: {
|
|
387
|
+
const range = value as TIppRange;
|
|
388
|
+
this.writeInt16(8);
|
|
389
|
+
this.writeInt32(range.lower);
|
|
390
|
+
this.writeInt32(range.upper);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case TAG_VALUE.DATE_TIME: {
|
|
395
|
+
const date = value as Date;
|
|
396
|
+
this.writeInt16(11);
|
|
397
|
+
this.writeInt16(date.getUTCFullYear());
|
|
398
|
+
this.writeInt8(date.getUTCMonth() + 1);
|
|
399
|
+
this.writeInt8(date.getUTCDate());
|
|
400
|
+
this.writeInt8(date.getUTCHours());
|
|
401
|
+
this.writeInt8(date.getUTCMinutes());
|
|
402
|
+
this.writeInt8(date.getUTCSeconds());
|
|
403
|
+
this.writeInt8(0); // deciseconds
|
|
404
|
+
this.writeInt8('+'.charCodeAt(0));
|
|
405
|
+
this.writeInt8(0); // UTC offset hours
|
|
406
|
+
this.writeInt8(0); // UTC offset minutes
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
default:
|
|
411
|
+
// String types
|
|
412
|
+
this.writeString(String(value));
|
|
413
|
+
break;
|
|
194
414
|
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ============================================================================
|
|
419
|
+
// IPP Message Decoder
|
|
420
|
+
// ============================================================================
|
|
195
421
|
|
|
196
|
-
|
|
197
|
-
|
|
422
|
+
class IppDecoder {
|
|
423
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
424
|
+
private offset: number = 0;
|
|
425
|
+
|
|
426
|
+
public decode(data: Buffer): IIppMessage {
|
|
427
|
+
this.buffer = data;
|
|
428
|
+
this.offset = 0;
|
|
429
|
+
|
|
430
|
+
const version = this.readInt16();
|
|
431
|
+
const operationIdOrStatusCode = this.readInt16();
|
|
432
|
+
const requestId = this.readInt32();
|
|
433
|
+
|
|
434
|
+
const message: IIppMessage = {
|
|
435
|
+
version,
|
|
436
|
+
operationIdOrStatusCode,
|
|
437
|
+
requestId,
|
|
438
|
+
operationAttributes: {},
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// Read attribute groups
|
|
442
|
+
while (this.offset < this.buffer.length) {
|
|
443
|
+
const tag = this.readInt8();
|
|
444
|
+
|
|
445
|
+
if (tag === TAG_DELIMITER.END_OF_ATTRIBUTES) {
|
|
446
|
+
// Remaining data is document data
|
|
447
|
+
if (this.offset < this.buffer.length) {
|
|
448
|
+
message.data = this.buffer.subarray(this.offset);
|
|
449
|
+
}
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (tag === TAG_DELIMITER.OPERATION_ATTRIBUTES) {
|
|
454
|
+
message.operationAttributes = this.readAttributes();
|
|
455
|
+
} else if (tag === TAG_DELIMITER.JOB_ATTRIBUTES) {
|
|
456
|
+
message.jobAttributes = this.readAttributes();
|
|
457
|
+
} else if (tag === TAG_DELIMITER.PRINTER_ATTRIBUTES) {
|
|
458
|
+
message.printerAttributes = this.readAttributes();
|
|
459
|
+
} else if (tag === TAG_DELIMITER.UNSUPPORTED_ATTRIBUTES) {
|
|
460
|
+
message.unsupportedAttributes = this.readAttributes();
|
|
461
|
+
}
|
|
198
462
|
}
|
|
199
463
|
|
|
200
|
-
|
|
201
|
-
|
|
464
|
+
return message;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private readInt8(): number {
|
|
468
|
+
return this.buffer[this.offset++];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private readInt16(): number {
|
|
472
|
+
const value = this.buffer.readUInt16BE(this.offset);
|
|
473
|
+
this.offset += 2;
|
|
474
|
+
return value;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private readInt32(): number {
|
|
478
|
+
const value = this.buffer.readInt32BE(this.offset);
|
|
479
|
+
this.offset += 4;
|
|
480
|
+
return value;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private readUInt32(): number {
|
|
484
|
+
const value = this.buffer.readUInt32BE(this.offset);
|
|
485
|
+
this.offset += 4;
|
|
486
|
+
return value;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private readString(length: number): string {
|
|
490
|
+
const str = this.buffer.toString('utf-8', this.offset, this.offset + length);
|
|
491
|
+
this.offset += length;
|
|
492
|
+
return str;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private readAttributes(): Record<string, TIppValue> {
|
|
496
|
+
const attrs: Record<string, TIppValue> = {};
|
|
497
|
+
let currentName = '';
|
|
498
|
+
|
|
499
|
+
while (this.offset < this.buffer.length) {
|
|
500
|
+
const tag = this.buffer[this.offset];
|
|
501
|
+
|
|
502
|
+
// Check for delimiter tag (start of new group or end)
|
|
503
|
+
if (tag <= 0x0f) {
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this.offset++; // Consume the tag
|
|
508
|
+
|
|
509
|
+
const nameLength = this.readInt16();
|
|
510
|
+
const name = nameLength > 0 ? this.readString(nameLength) : currentName;
|
|
511
|
+
const valueLength = this.readInt16();
|
|
512
|
+
|
|
513
|
+
const value = this.readValue(tag, valueLength);
|
|
514
|
+
|
|
515
|
+
if (nameLength > 0) {
|
|
516
|
+
currentName = name;
|
|
517
|
+
attrs[name] = value;
|
|
518
|
+
} else {
|
|
519
|
+
// Additional value for same attribute - make it an array
|
|
520
|
+
const existing = attrs[currentName];
|
|
521
|
+
if (Array.isArray(existing)) {
|
|
522
|
+
existing.push(value);
|
|
523
|
+
} else {
|
|
524
|
+
attrs[currentName] = [existing, value];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
202
527
|
}
|
|
203
528
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
529
|
+
return attrs;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private readValue(tag: number, length: number): TIppValue {
|
|
533
|
+
switch (tag) {
|
|
534
|
+
case TAG_VALUE.INTEGER:
|
|
535
|
+
case TAG_VALUE.ENUM:
|
|
536
|
+
return this.readInt32();
|
|
537
|
+
|
|
538
|
+
case TAG_VALUE.BOOLEAN:
|
|
539
|
+
return this.readInt8() !== 0;
|
|
540
|
+
|
|
541
|
+
case TAG_VALUE.RESOLUTION: {
|
|
542
|
+
const crossFeed = this.readInt32();
|
|
543
|
+
const feed = this.readInt32();
|
|
544
|
+
const units = this.readInt8() === 3 ? 'dpi' : 'dpcm';
|
|
545
|
+
return { crossFeed, feed, units } as TIppResolution;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
case TAG_VALUE.RANGE_OF_INTEGER: {
|
|
549
|
+
const lower = this.readInt32();
|
|
550
|
+
const upper = this.readInt32();
|
|
551
|
+
return { lower, upper } as TIppRange;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
case TAG_VALUE.DATE_TIME: {
|
|
555
|
+
const year = this.readInt16();
|
|
556
|
+
const month = this.readInt8();
|
|
557
|
+
const day = this.readInt8();
|
|
558
|
+
const hour = this.readInt8();
|
|
559
|
+
const minute = this.readInt8();
|
|
560
|
+
const second = this.readInt8();
|
|
561
|
+
this.readInt8(); // deciseconds
|
|
562
|
+
const direction = String.fromCharCode(this.readInt8());
|
|
563
|
+
const offsetHours = this.readInt8();
|
|
564
|
+
const offsetMinutes = this.readInt8();
|
|
565
|
+
|
|
566
|
+
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
|
567
|
+
const offsetMs = (offsetHours * 60 + offsetMinutes) * 60 * 1000;
|
|
568
|
+
if (direction === '-') {
|
|
569
|
+
date.setTime(date.getTime() + offsetMs);
|
|
570
|
+
} else {
|
|
571
|
+
date.setTime(date.getTime() - offsetMs);
|
|
572
|
+
}
|
|
573
|
+
return date;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
case TAG_VALUE.NO_VALUE:
|
|
577
|
+
case TAG_VALUE.UNKNOWN:
|
|
578
|
+
case TAG_VALUE.UNSUPPORTED:
|
|
579
|
+
this.offset += length;
|
|
580
|
+
return '';
|
|
581
|
+
|
|
582
|
+
default:
|
|
583
|
+
// String types
|
|
584
|
+
return this.readString(length);
|
|
211
585
|
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
212
588
|
|
|
213
|
-
|
|
214
|
-
|
|
589
|
+
// ============================================================================
|
|
590
|
+
// Status Code Helpers
|
|
591
|
+
// ============================================================================
|
|
592
|
+
|
|
593
|
+
function isSuccessful(statusCode: number): boolean {
|
|
594
|
+
return statusCode >= 0x0000 && statusCode <= 0x00ff;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function statusCodeToString(code: number): string {
|
|
598
|
+
const statusMap: Record<number, string> = {
|
|
599
|
+
0x0000: 'successful-ok',
|
|
600
|
+
0x0001: 'successful-ok-ignored-or-substituted-attributes',
|
|
601
|
+
0x0002: 'successful-ok-conflicting-attributes',
|
|
602
|
+
0x0400: 'client-error-bad-request',
|
|
603
|
+
0x0401: 'client-error-forbidden',
|
|
604
|
+
0x0402: 'client-error-not-authenticated',
|
|
605
|
+
0x0403: 'client-error-not-authorized',
|
|
606
|
+
0x0404: 'client-error-not-possible',
|
|
607
|
+
0x0405: 'client-error-timeout',
|
|
608
|
+
0x0406: 'client-error-not-found',
|
|
609
|
+
0x040a: 'client-error-document-format-not-supported',
|
|
610
|
+
0x040b: 'client-error-attributes-or-values-not-supported',
|
|
611
|
+
0x0500: 'server-error-internal-error',
|
|
612
|
+
0x0501: 'server-error-operation-not-supported',
|
|
613
|
+
0x0502: 'server-error-service-unavailable',
|
|
614
|
+
0x0504: 'server-error-device-error',
|
|
615
|
+
0x0506: 'server-error-not-accepting-jobs',
|
|
616
|
+
0x0507: 'server-error-busy',
|
|
617
|
+
};
|
|
618
|
+
return statusMap[code] || `unknown-status-${code.toString(16)}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// IPP Client
|
|
623
|
+
// ============================================================================
|
|
624
|
+
|
|
625
|
+
export class IppClient {
|
|
626
|
+
private readonly printerUri: string;
|
|
627
|
+
private readonly httpUri: string;
|
|
628
|
+
private requestId: number = 1;
|
|
629
|
+
private readonly encoder = new IppEncoder();
|
|
630
|
+
private readonly decoder = new IppDecoder();
|
|
631
|
+
|
|
632
|
+
constructor(
|
|
633
|
+
address: string,
|
|
634
|
+
port: number = 631,
|
|
635
|
+
path: string = '/ipp/print'
|
|
636
|
+
) {
|
|
637
|
+
this.printerUri = `ipp://${address}:${port}${path}`;
|
|
638
|
+
this.httpUri = `http://${address}:${port}${path}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ==========================================================================
|
|
642
|
+
// Core Request Method
|
|
643
|
+
// ==========================================================================
|
|
644
|
+
|
|
645
|
+
private async request(message: IIppMessage): Promise<IIppMessage> {
|
|
646
|
+
const requestData = this.encoder.encode(message);
|
|
647
|
+
|
|
648
|
+
const response = await fetch(this.httpUri, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: {
|
|
651
|
+
'Content-Type': 'application/ipp',
|
|
652
|
+
'Accept': 'application/ipp',
|
|
653
|
+
},
|
|
654
|
+
body: new Uint8Array(requestData),
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
if (!response.ok) {
|
|
658
|
+
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
|
|
215
659
|
}
|
|
216
660
|
|
|
217
|
-
const
|
|
218
|
-
|
|
661
|
+
const responseData = Buffer.from(await response.arrayBuffer());
|
|
662
|
+
return this.decoder.decode(responseData);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ==========================================================================
|
|
666
|
+
// Get Printer Attributes
|
|
667
|
+
// ==========================================================================
|
|
668
|
+
|
|
669
|
+
public async getPrinterAttributes(): Promise<IIppPrinterCapabilities> {
|
|
670
|
+
const message: IIppMessage = {
|
|
671
|
+
version: IPP_VERSION.V2_0,
|
|
672
|
+
operationIdOrStatusCode: IPP_OPERATION.GET_PRINTER_ATTRIBUTES,
|
|
673
|
+
requestId: this.requestId++,
|
|
674
|
+
operationAttributes: {
|
|
675
|
+
'attributes-charset': 'utf-8',
|
|
676
|
+
'attributes-natural-language': 'en-us',
|
|
677
|
+
'printer-uri': this.printerUri,
|
|
678
|
+
},
|
|
219
679
|
};
|
|
220
680
|
|
|
221
|
-
|
|
222
|
-
|
|
681
|
+
const response = await this.request(message);
|
|
682
|
+
|
|
683
|
+
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
684
|
+
throw new Error(`IPP error: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
|
223
685
|
}
|
|
224
686
|
|
|
225
|
-
return
|
|
687
|
+
return this.parseCapabilities(response.printerAttributes || {});
|
|
226
688
|
}
|
|
227
689
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
690
|
+
private parseCapabilities(attrs: Record<string, TIppValue>): IIppPrinterCapabilities {
|
|
691
|
+
const getString = (key: string, defaultVal: string = ''): string => {
|
|
692
|
+
const val = attrs[key];
|
|
693
|
+
if (typeof val === 'string') return val;
|
|
694
|
+
if (Array.isArray(val) && val.length > 0) return String(val[0]);
|
|
695
|
+
return defaultVal;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const getStringArray = (key: string): string[] => {
|
|
699
|
+
const val = attrs[key];
|
|
700
|
+
if (Array.isArray(val)) return val.map(String);
|
|
701
|
+
if (val !== undefined) return [String(val)];
|
|
236
702
|
return [];
|
|
237
703
|
};
|
|
238
704
|
|
|
239
|
-
const getNumber = (key: string, defaultVal: number): number => {
|
|
240
|
-
const
|
|
241
|
-
if (typeof
|
|
242
|
-
if (typeof value === 'string') return parseInt(value) || defaultVal;
|
|
705
|
+
const getNumber = (key: string, defaultVal: number = 0): number => {
|
|
706
|
+
const val = attrs[key];
|
|
707
|
+
if (typeof val === 'number') return val;
|
|
243
708
|
return defaultVal;
|
|
244
709
|
};
|
|
245
710
|
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
if (typeof
|
|
249
|
-
if (
|
|
250
|
-
|
|
711
|
+
const getNumberArray = (key: string): number[] => {
|
|
712
|
+
const val = attrs[key];
|
|
713
|
+
if (Array.isArray(val)) return val.filter((v): v is number => typeof v === 'number');
|
|
714
|
+
if (typeof val === 'number') return [val];
|
|
715
|
+
return [];
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const getBool = (key: string, defaultVal: boolean = false): boolean => {
|
|
719
|
+
const val = attrs[key];
|
|
720
|
+
if (typeof val === 'boolean') return val;
|
|
251
721
|
return defaultVal;
|
|
252
722
|
};
|
|
253
723
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
for (const res of resSupported) {
|
|
259
|
-
if (typeof res === 'object' && res !== null && 'x' in res) {
|
|
260
|
-
resolutions.push((res as { x: number }).x);
|
|
261
|
-
} else if (typeof res === 'number') {
|
|
262
|
-
resolutions.push(res);
|
|
263
|
-
}
|
|
724
|
+
const getRange = (key: string): TIppRange => {
|
|
725
|
+
const val = attrs[key];
|
|
726
|
+
if (val && typeof val === 'object' && 'lower' in val) {
|
|
727
|
+
return val as TIppRange;
|
|
264
728
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
729
|
+
return { lower: 1, upper: 1 };
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const getResolutions = (): TIppResolution[] => {
|
|
733
|
+
const val = attrs['printer-resolution-supported'];
|
|
734
|
+
if (!val) return [];
|
|
735
|
+
|
|
736
|
+
const values = Array.isArray(val) ? val : [val];
|
|
737
|
+
return values
|
|
738
|
+
.filter((v): v is TIppResolution =>
|
|
739
|
+
typeof v === 'object' && v !== null && 'crossFeed' in v
|
|
740
|
+
);
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
// Map printer state number to string
|
|
744
|
+
const printerState = getNumber('printer-state', PRINTER_STATE.IDLE);
|
|
745
|
+
const printerStateString = printerState === PRINTER_STATE.IDLE ? 'idle' :
|
|
746
|
+
printerState === PRINTER_STATE.PROCESSING ? 'processing' : 'stopped';
|
|
269
747
|
|
|
270
748
|
return {
|
|
749
|
+
printerName: getString('printer-name', 'Unknown Printer'),
|
|
750
|
+
printerInfo: getString('printer-info') || undefined,
|
|
751
|
+
printerMakeAndModel: getString('printer-make-and-model') || undefined,
|
|
752
|
+
printerLocation: getString('printer-location') || undefined,
|
|
753
|
+
printerUri: this.printerUri,
|
|
754
|
+
|
|
755
|
+
printerState: printerStateString,
|
|
756
|
+
printerStateReasons: getStringArray('printer-state-reasons'),
|
|
757
|
+
printerIsAcceptingJobs: getBool('printer-is-accepting-jobs', true),
|
|
758
|
+
queuedJobCount: getNumber('queued-job-count', 0),
|
|
759
|
+
|
|
760
|
+
documentFormatSupported: getStringArray('document-format-supported'),
|
|
761
|
+
documentFormatDefault: getString('document-format-default') || undefined,
|
|
762
|
+
|
|
763
|
+
mediaSizeSupported: getStringArray('media-supported'),
|
|
764
|
+
mediaDefault: getString('media-default') || undefined,
|
|
765
|
+
mediaTypeSupported: getStringArray('media-type-supported'),
|
|
766
|
+
|
|
271
767
|
colorSupported: getBool('color-supported', false),
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
sidesSupported: getArray('sides-supported'),
|
|
281
|
-
qualitySupported: getArray('print-quality-supported').map(String),
|
|
768
|
+
sidesSupported: getStringArray('sides-supported'),
|
|
769
|
+
sidesDefault: getString('sides-default') || undefined,
|
|
770
|
+
copiesSupported: getRange('copies-supported'),
|
|
771
|
+
printQualitySupported: getNumberArray('print-quality-supported'),
|
|
772
|
+
resolutionsSupported: getResolutions(),
|
|
773
|
+
|
|
774
|
+
operationsSupported: getNumberArray('operations-supported'),
|
|
775
|
+
ippVersionsSupported: getStringArray('ipp-versions-supported'),
|
|
282
776
|
};
|
|
283
777
|
}
|
|
284
778
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
779
|
+
// ==========================================================================
|
|
780
|
+
// Print Job
|
|
781
|
+
// ==========================================================================
|
|
782
|
+
|
|
783
|
+
public async printJob(
|
|
784
|
+
data: Buffer,
|
|
785
|
+
options: IIppPrintOptions = {}
|
|
786
|
+
): Promise<IIppJob> {
|
|
787
|
+
// Build operation attributes
|
|
788
|
+
const operationAttrs: Record<string, TIppValue> = {
|
|
789
|
+
'attributes-charset': 'utf-8',
|
|
790
|
+
'attributes-natural-language': 'en-us',
|
|
791
|
+
'printer-uri': this.printerUri,
|
|
792
|
+
'requesting-user-name': options.requestingUserName || 'devicemanager',
|
|
793
|
+
'job-name': options.jobName || 'Print Job',
|
|
794
|
+
'document-format': options.documentFormat || 'application/octet-stream',
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// Build job attributes
|
|
798
|
+
const jobAttrs: Record<string, TIppValue> = {};
|
|
799
|
+
|
|
800
|
+
if (options.copies && options.copies > 1) {
|
|
801
|
+
jobAttrs['copies'] = options.copies;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (options.sides) {
|
|
805
|
+
jobAttrs['sides'] = options.sides;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (options.media) {
|
|
809
|
+
jobAttrs['media'] = options.media;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (options.printQuality) {
|
|
813
|
+
const qualityMap = { draft: 3, normal: 4, high: 5 };
|
|
814
|
+
jobAttrs['print-quality'] = qualityMap[options.printQuality];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (options.colorMode) {
|
|
818
|
+
jobAttrs['print-color-mode'] = options.colorMode;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (options.orientation) {
|
|
822
|
+
const orientationMap = {
|
|
823
|
+
portrait: 3,
|
|
824
|
+
landscape: 4,
|
|
825
|
+
'reverse-landscape': 5,
|
|
826
|
+
'reverse-portrait': 6,
|
|
827
|
+
};
|
|
828
|
+
jobAttrs['orientation-requested'] = orientationMap[options.orientation];
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const message: IIppMessage = {
|
|
832
|
+
version: IPP_VERSION.V2_0,
|
|
833
|
+
operationIdOrStatusCode: IPP_OPERATION.PRINT_JOB,
|
|
834
|
+
requestId: this.requestId++,
|
|
835
|
+
operationAttributes: operationAttrs,
|
|
836
|
+
jobAttributes: Object.keys(jobAttrs).length > 0 ? jobAttrs : undefined,
|
|
837
|
+
data,
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const response = await this.request(message);
|
|
841
|
+
|
|
842
|
+
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
843
|
+
throw new Error(`Print failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return this.parseJob(response.jobAttributes || {});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ==========================================================================
|
|
850
|
+
// Get Jobs
|
|
851
|
+
// ==========================================================================
|
|
852
|
+
|
|
853
|
+
public async getJobs(whichJobs: 'completed' | 'not-completed' | 'all' = 'not-completed'): Promise<IIppJob[]> {
|
|
854
|
+
const message: IIppMessage = {
|
|
855
|
+
version: IPP_VERSION.V2_0,
|
|
856
|
+
operationIdOrStatusCode: IPP_OPERATION.GET_JOBS,
|
|
857
|
+
requestId: this.requestId++,
|
|
858
|
+
operationAttributes: {
|
|
859
|
+
'attributes-charset': 'utf-8',
|
|
860
|
+
'attributes-natural-language': 'en-us',
|
|
861
|
+
'printer-uri': this.printerUri,
|
|
862
|
+
'requesting-user-name': 'devicemanager',
|
|
863
|
+
'which-jobs': whichJobs,
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const response = await this.request(message);
|
|
868
|
+
|
|
869
|
+
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
870
|
+
throw new Error(`Get jobs failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Jobs can come back as a single job-attributes-tag or multiple
|
|
874
|
+
// The decoder merges additional values into arrays
|
|
875
|
+
if (!response.jobAttributes) {
|
|
876
|
+
return [];
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// If job-id is an array, we have multiple jobs encoded together
|
|
880
|
+
// This is a limitation of the simple decoder - in reality each job is a separate group
|
|
881
|
+
// For now, return single job if present
|
|
882
|
+
return [this.parseJob(response.jobAttributes)];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ==========================================================================
|
|
886
|
+
// Get Job Attributes
|
|
887
|
+
// ==========================================================================
|
|
888
|
+
|
|
889
|
+
public async getJobAttributes(jobId: number): Promise<IIppJob> {
|
|
890
|
+
const message: IIppMessage = {
|
|
891
|
+
version: IPP_VERSION.V2_0,
|
|
892
|
+
operationIdOrStatusCode: IPP_OPERATION.GET_JOB_ATTRIBUTES,
|
|
893
|
+
requestId: this.requestId++,
|
|
894
|
+
operationAttributes: {
|
|
895
|
+
'attributes-charset': 'utf-8',
|
|
896
|
+
'attributes-natural-language': 'en-us',
|
|
897
|
+
'printer-uri': this.printerUri,
|
|
898
|
+
'job-id': jobId,
|
|
899
|
+
},
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const response = await this.request(message);
|
|
903
|
+
|
|
904
|
+
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
905
|
+
throw new Error(`Get job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return this.parseJob(response.jobAttributes || {});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ==========================================================================
|
|
912
|
+
// Cancel Job
|
|
913
|
+
// ==========================================================================
|
|
914
|
+
|
|
915
|
+
public async cancelJob(jobId: number): Promise<void> {
|
|
916
|
+
const message: IIppMessage = {
|
|
917
|
+
version: IPP_VERSION.V2_0,
|
|
918
|
+
operationIdOrStatusCode: IPP_OPERATION.CANCEL_JOB,
|
|
919
|
+
requestId: this.requestId++,
|
|
920
|
+
operationAttributes: {
|
|
921
|
+
'attributes-charset': 'utf-8',
|
|
922
|
+
'attributes-natural-language': 'en-us',
|
|
923
|
+
'printer-uri': this.printerUri,
|
|
924
|
+
'job-id': jobId,
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const response = await this.request(message);
|
|
929
|
+
|
|
930
|
+
if (!isSuccessful(response.operationIdOrStatusCode)) {
|
|
931
|
+
throw new Error(`Cancel job failed: ${statusCodeToString(response.operationIdOrStatusCode)}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ==========================================================================
|
|
936
|
+
// Validate Job (check if job would succeed without actually printing)
|
|
937
|
+
// ==========================================================================
|
|
938
|
+
|
|
939
|
+
public async validateJob(options: IIppPrintOptions = {}): Promise<boolean> {
|
|
940
|
+
const operationAttrs: Record<string, TIppValue> = {
|
|
941
|
+
'attributes-charset': 'utf-8',
|
|
942
|
+
'attributes-natural-language': 'en-us',
|
|
943
|
+
'printer-uri': this.printerUri,
|
|
944
|
+
'document-format': options.documentFormat || 'application/octet-stream',
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const message: IIppMessage = {
|
|
948
|
+
version: IPP_VERSION.V2_0,
|
|
949
|
+
operationIdOrStatusCode: IPP_OPERATION.VALIDATE_JOB,
|
|
950
|
+
requestId: this.requestId++,
|
|
951
|
+
operationAttributes: operationAttrs,
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const response = await this.request(message);
|
|
955
|
+
return isSuccessful(response.operationIdOrStatusCode);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ==========================================================================
|
|
959
|
+
// Helper Methods
|
|
960
|
+
// ==========================================================================
|
|
961
|
+
|
|
962
|
+
private parseJob(attrs: Record<string, TIppValue>): IIppJob {
|
|
963
|
+
const getNumber = (key: string, defaultVal: number = 0): number => {
|
|
964
|
+
const val = attrs[key];
|
|
965
|
+
if (typeof val === 'number') return val;
|
|
966
|
+
if (Array.isArray(val) && typeof val[0] === 'number') return val[0];
|
|
293
967
|
return defaultVal;
|
|
294
968
|
};
|
|
295
969
|
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
if (typeof
|
|
299
|
-
if (
|
|
970
|
+
const getString = (key: string, defaultVal: string = ''): string => {
|
|
971
|
+
const val = attrs[key];
|
|
972
|
+
if (typeof val === 'string') return val;
|
|
973
|
+
if (Array.isArray(val) && val.length > 0) return String(val[0]);
|
|
300
974
|
return defaultVal;
|
|
301
975
|
};
|
|
302
976
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
5: 'processing', // processing
|
|
309
|
-
6: 'processing', // processing-stopped
|
|
310
|
-
7: 'canceled', // canceled
|
|
311
|
-
8: 'aborted', // aborted
|
|
312
|
-
9: 'completed', // completed
|
|
977
|
+
const getStringArray = (key: string): string[] => {
|
|
978
|
+
const val = attrs[key];
|
|
979
|
+
if (Array.isArray(val)) return val.map(String);
|
|
980
|
+
if (val !== undefined) return [String(val)];
|
|
981
|
+
return [];
|
|
313
982
|
};
|
|
314
983
|
|
|
315
|
-
const
|
|
316
|
-
|
|
984
|
+
const getDate = (key: string): Date | undefined => {
|
|
985
|
+
const val = attrs[key];
|
|
986
|
+
if (val instanceof Date) return val;
|
|
987
|
+
return undefined;
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// Map job state number to string
|
|
991
|
+
const jobState = getNumber('job-state', JOB_STATE.PENDING);
|
|
992
|
+
const jobStateMap: Record<number, IIppJob['state']> = {
|
|
993
|
+
[JOB_STATE.PENDING]: 'pending',
|
|
994
|
+
[JOB_STATE.PENDING_HELD]: 'pending-held',
|
|
995
|
+
[JOB_STATE.PROCESSING]: 'processing',
|
|
996
|
+
[JOB_STATE.PROCESSING_STOPPED]: 'processing-stopped',
|
|
997
|
+
[JOB_STATE.CANCELED]: 'canceled',
|
|
998
|
+
[JOB_STATE.ABORTED]: 'aborted',
|
|
999
|
+
[JOB_STATE.COMPLETED]: 'completed',
|
|
1000
|
+
};
|
|
317
1001
|
|
|
318
1002
|
return {
|
|
319
|
-
id: getNumber('job-id'
|
|
1003
|
+
id: getNumber('job-id'),
|
|
1004
|
+
uri: getString('job-uri'),
|
|
1005
|
+
state: jobStateMap[jobState] || 'pending',
|
|
1006
|
+
stateReasons: getStringArray('job-state-reasons'),
|
|
320
1007
|
name: getString('job-name', 'Unknown Job'),
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1008
|
+
originatingUserName: getString('job-originating-user-name') || undefined,
|
|
1009
|
+
createdAt: getDate('time-at-creation') || getDate('date-time-at-creation'),
|
|
1010
|
+
completedAt: getDate('time-at-completed') || getDate('date-time-at-completed'),
|
|
1011
|
+
processingAt: getDate('time-at-processing') || getDate('date-time-at-processing'),
|
|
1012
|
+
impressionsCompleted: getNumber('job-impressions-completed') || undefined,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ==========================================================================
|
|
1017
|
+
// Convenience: Check if format is supported
|
|
1018
|
+
// ==========================================================================
|
|
1019
|
+
|
|
1020
|
+
public async isFormatSupported(mimeType: string): Promise<boolean> {
|
|
1021
|
+
const caps = await this.getPrinterAttributes();
|
|
1022
|
+
return caps.documentFormatSupported.includes(mimeType);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ==========================================================================
|
|
1026
|
+
// Convenience: Get best format for data
|
|
1027
|
+
// ==========================================================================
|
|
1028
|
+
|
|
1029
|
+
public async getBestFormat(preferredFormats: string[]): Promise<string | null> {
|
|
1030
|
+
const caps = await this.getPrinterAttributes();
|
|
1031
|
+
const supported = caps.documentFormatSupported;
|
|
1032
|
+
|
|
1033
|
+
for (const format of preferredFormats) {
|
|
1034
|
+
if (supported.includes(format)) {
|
|
1035
|
+
return format;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ==========================================================================
|
|
1043
|
+
// Smart Print: Auto-detect format and convert if needed
|
|
1044
|
+
// ==========================================================================
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Smart print that auto-detects document format and converts if necessary.
|
|
1048
|
+
*
|
|
1049
|
+
* If the document format is not supported by the printer, it will attempt
|
|
1050
|
+
* to convert to a supported format (e.g., PDF → JPEG).
|
|
1051
|
+
*/
|
|
1052
|
+
public async smartPrint(
|
|
1053
|
+
data: Buffer,
|
|
1054
|
+
options: IIppPrintOptions = {}
|
|
1055
|
+
): Promise<IIppJob> {
|
|
1056
|
+
// Detect format from data
|
|
1057
|
+
const detectedFormat = this.detectFormat(data);
|
|
1058
|
+
console.log(`[IPP] Detected format: ${detectedFormat}`);
|
|
1059
|
+
|
|
1060
|
+
// Get printer capabilities
|
|
1061
|
+
const caps = await this.getPrinterAttributes();
|
|
1062
|
+
const supported = caps.documentFormatSupported;
|
|
1063
|
+
|
|
1064
|
+
// Check if detected format is supported
|
|
1065
|
+
if (supported.includes(detectedFormat)) {
|
|
1066
|
+
console.log(`[IPP] Format ${detectedFormat} is supported, printing directly`);
|
|
1067
|
+
return this.printJob(data, {
|
|
1068
|
+
...options,
|
|
1069
|
+
documentFormat: detectedFormat,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Format not supported - try to convert
|
|
1074
|
+
console.log(`[IPP] Format ${detectedFormat} not supported, attempting conversion`);
|
|
1075
|
+
|
|
1076
|
+
// Determine best target format
|
|
1077
|
+
const conversionTargets = ['image/jpeg', 'image/png', 'image/pwg-raster'];
|
|
1078
|
+
const targetFormat = conversionTargets.find(f => supported.includes(f));
|
|
1079
|
+
|
|
1080
|
+
if (!targetFormat) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
`Document format ${detectedFormat} not supported and no conversion target available. ` +
|
|
1083
|
+
`Printer supports: ${supported.join(', ')}`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Convert the document
|
|
1088
|
+
const convertedData = await this.convertDocument(data, detectedFormat, targetFormat);
|
|
1089
|
+
console.log(`[IPP] Converted to ${targetFormat} (${convertedData.length} bytes)`);
|
|
1090
|
+
|
|
1091
|
+
return this.printJob(convertedData, {
|
|
1092
|
+
...options,
|
|
1093
|
+
documentFormat: targetFormat,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Detect document format from magic bytes
|
|
1099
|
+
*/
|
|
1100
|
+
private detectFormat(data: Buffer): string {
|
|
1101
|
+
if (data.length < 4) {
|
|
1102
|
+
return 'application/octet-stream';
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// PDF: starts with %PDF
|
|
1106
|
+
if (data[0] === 0x25 && data[1] === 0x50 && data[2] === 0x44 && data[3] === 0x46) {
|
|
1107
|
+
return 'application/pdf';
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// JPEG: starts with FFD8FF
|
|
1111
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
|
|
1112
|
+
return 'image/jpeg';
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// PNG: starts with 89504E47
|
|
1116
|
+
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
|
1117
|
+
return 'image/png';
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// TIFF: starts with 49492A00 (little endian) or 4D4D002A (big endian)
|
|
1121
|
+
if ((data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) ||
|
|
1122
|
+
(data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)) {
|
|
1123
|
+
return 'image/tiff';
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// PostScript: starts with %!
|
|
1127
|
+
if (data[0] === 0x25 && data[1] === 0x21) {
|
|
1128
|
+
return 'application/postscript';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// PWG Raster: starts with "RaS2"
|
|
1132
|
+
if (data[0] === 0x52 && data[1] === 0x61 && data[2] === 0x53 && data[3] === 0x32) {
|
|
1133
|
+
return 'image/pwg-raster';
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// URF (Apple Raster): starts with "UNIRAST"
|
|
1137
|
+
if (data.length >= 8 &&
|
|
1138
|
+
data[0] === 0x55 && data[1] === 0x4e && data[2] === 0x49 && data[3] === 0x52 &&
|
|
1139
|
+
data[4] === 0x41 && data[5] === 0x53 && data[6] === 0x54) {
|
|
1140
|
+
return 'image/urf';
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return 'application/octet-stream';
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Convert document to target format
|
|
1148
|
+
* Uses ImageMagick if available, otherwise throws
|
|
1149
|
+
*/
|
|
1150
|
+
private async convertDocument(
|
|
1151
|
+
data: Buffer,
|
|
1152
|
+
sourceFormat: string,
|
|
1153
|
+
targetFormat: string
|
|
1154
|
+
): Promise<Buffer> {
|
|
1155
|
+
// Try using ImageMagick via child_process
|
|
1156
|
+
const { execSync, spawnSync } = await import('child_process');
|
|
1157
|
+
const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = await import('fs');
|
|
1158
|
+
const { join } = await import('path');
|
|
1159
|
+
const { tmpdir } = await import('os');
|
|
1160
|
+
|
|
1161
|
+
// Check if ImageMagick is available
|
|
1162
|
+
try {
|
|
1163
|
+
execSync('which convert', { stdio: 'ignore' });
|
|
1164
|
+
} catch {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
`Cannot convert ${sourceFormat} to ${targetFormat}: ImageMagick not available`
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Create temp directory and files
|
|
1171
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'ipp-convert-'));
|
|
1172
|
+
const sourceExt = this.formatToExtension(sourceFormat);
|
|
1173
|
+
const targetExt = this.formatToExtension(targetFormat);
|
|
1174
|
+
const sourcePath = join(tempDir, `input${sourceExt}`);
|
|
1175
|
+
const targetPath = join(tempDir, `output${targetExt}`);
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
// Write source file
|
|
1179
|
+
writeFileSync(sourcePath, data);
|
|
1180
|
+
|
|
1181
|
+
// Build convert command
|
|
1182
|
+
const args = [
|
|
1183
|
+
sourcePath,
|
|
1184
|
+
'-density', '300', // Good DPI for print
|
|
1185
|
+
'-quality', '90', // JPEG quality
|
|
1186
|
+
'-background', 'white', // White background for transparency
|
|
1187
|
+
'-flatten', // Flatten layers
|
|
1188
|
+
targetPath,
|
|
1189
|
+
];
|
|
1190
|
+
|
|
1191
|
+
// For multi-page PDFs, only convert first page
|
|
1192
|
+
if (sourceFormat === 'application/pdf') {
|
|
1193
|
+
args[0] = `${sourcePath}[0]`; // First page only
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Run conversion
|
|
1197
|
+
const result = spawnSync('convert', args, {
|
|
1198
|
+
timeout: 30000,
|
|
1199
|
+
maxBuffer: 100 * 1024 * 1024, // 100MB
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
if (result.status !== 0) {
|
|
1203
|
+
const stderr = result.stderr?.toString() || 'Unknown error';
|
|
1204
|
+
throw new Error(`ImageMagick conversion failed: ${stderr}`);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Read converted file
|
|
1208
|
+
return readFileSync(targetPath);
|
|
1209
|
+
|
|
1210
|
+
} finally {
|
|
1211
|
+
// Cleanup temp files
|
|
1212
|
+
try { unlinkSync(sourcePath); } catch {}
|
|
1213
|
+
try { unlinkSync(targetPath); } catch {}
|
|
1214
|
+
try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch {}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Get file extension for MIME type
|
|
1220
|
+
*/
|
|
1221
|
+
private formatToExtension(mimeType: string): string {
|
|
1222
|
+
const extensions: Record<string, string> = {
|
|
1223
|
+
'application/pdf': '.pdf',
|
|
1224
|
+
'image/jpeg': '.jpg',
|
|
1225
|
+
'image/png': '.png',
|
|
1226
|
+
'image/tiff': '.tiff',
|
|
1227
|
+
'application/postscript': '.ps',
|
|
1228
|
+
'image/pwg-raster': '.pwg',
|
|
1229
|
+
'image/urf': '.urf',
|
|
1230
|
+
'application/octet-stream': '.bin',
|
|
327
1231
|
};
|
|
1232
|
+
return extensions[mimeType] || '.bin';
|
|
328
1233
|
}
|
|
1234
|
+
|
|
329
1235
|
}
|
|
1236
|
+
|
|
1237
|
+
// Export as IppProtocol for consistency with other protocols
|
|
1238
|
+
export { IppClient as IppProtocol };
|
|
1239
|
+
|
|
1240
|
+
// ============================================================================
|
|
1241
|
+
// Exports
|
|
1242
|
+
// ============================================================================
|
|
1243
|
+
|
|
1244
|
+
export {
|
|
1245
|
+
IPP_VERSION,
|
|
1246
|
+
IPP_OPERATION,
|
|
1247
|
+
IPP_STATUS,
|
|
1248
|
+
JOB_STATE,
|
|
1249
|
+
PRINTER_STATE,
|
|
1250
|
+
isSuccessful,
|
|
1251
|
+
statusCodeToString,
|
|
1252
|
+
};
|