@gohcltech/edge-print-client 2.0.81-develop → 2.0.89-develop

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/README.md CHANGED
@@ -6,6 +6,10 @@ Intended as a drop-in replacement for qz-tray.js with a simpler token-based auth
6
6
 
7
7
  ---
8
8
 
9
+ > **Upgrading from 1.x?** See [MIGRATING.md](../MIGRATING.md). `print()`'s
10
+ > return type changed, printed output changes on both platforms, and the
11
+ > `virtual` field you may be filtering on never worked.
12
+
9
13
  ## Prerequisites
10
14
 
11
15
  1. **Edge Printing agent** must be installed and running on the user's machine. The agent listens on `wss://127.0.0.1:8181` by default.
@@ -136,9 +140,11 @@ the substitution this release exists to remove. Three things soften it:
136
140
  - **The rejection tells you what would work.** `unsupported_value` carries
137
141
  `field` and a `supported` array of the printer's real values.
138
142
 
139
- `paperSize` and `tray` are only checked on paths that can apply them; `raw`,
140
- `html` and network targets never did, so they are left alone rather than
141
- starting to fail.
143
+ `paperSize` and `tray` are only *validated* on paths that can act on them, so
144
+ `raw` and `html` content and network targets do not start failing. Validated is
145
+ not the same as unused: on macOS both settings are still passed to CUPS as
146
+ `media=` and `InputSlot=` for raw and HTML jobs, as they were in 1.x. On
147
+ Windows those paths genuinely do discard them.
142
148
 
143
149
  **On `mediaTypes`.** The field is usually absent. Of eleven drivers surveyed,
144
150
  eight listed no stock types at all, and a ninth listed them only under a
@@ -192,10 +198,13 @@ const { jobId, warnings } = await ep.print(config, data)
192
198
  | `jobId` | `string` | The agent's id for the job, matching its entry in the job history |
193
199
  | `warnings` | `JobWarning[]` | Caveats the agent recorded about a job it printed anyway. Empty in the common case |
194
200
 
195
- Each `JobWarning` is `{ code, message }`. Today the only code is
196
- `capability_unverified` — the agent could not determine a capability, so it
197
- skipped the check and passed the request through. Treat the set as open-ended:
198
- log or surface `message` rather than branching on every code.
201
+ Each `JobWarning` is `{ code, message }`. Two codes are raised today:
202
+ `capability_unverified`, where the agent could not determine a capability so it
203
+ skipped the check and passed the request through, and `content_scaled_to_fit`,
204
+ where content that did not fit was scaled down rather than clipped. What it had
205
+ to fit is the sheet when you sent no `margins` and the margin box when you did,
206
+ so content well inside the sheet can still warn. Treat the set as open-ended: log or surface
207
+ `message` rather than branching on every code.
199
208
 
200
209
  > **Breaking change.** `print()` used to resolve to the job id string. It now
201
210
  > resolves to an object. Destructure it (`const { jobId } = await ep.print(…)`),
@@ -249,9 +258,13 @@ Two limits worth knowing before you build on this:
249
258
  | `orientation` | `'portrait' \| 'landscape'?` | Page orientation |
250
259
  | `jobName` | `string?` | Name shown in the OS print queue |
251
260
  | `colorType` | `'color' \| 'grayscale' \| 'black-white'?` | Color mode |
252
- | `paperSize` | `string?` | Paper size from `PrinterInfo.papers` (e.g. `"Letter"`). Rejects if the printer lists paper sizes and this is not one — see below |
253
- | `tray` | `string?` | Tray name from `TrayInfo.name` (e.g. `"Tray 1"`). Rejects if the printer lists trays and this is not one — see below |
254
- | `mediaType` | `string?` | Stock type from `PrinterInfo.mediaTypes` (e.g. `"Bond"`). Rejected for `raw`/`html` content and network targets |
261
+ | `paperSize` | `string?` | Paper size from `PrinterInfo.papers` (e.g. `"Letter"`). Rejects with `unsupported_value` if the printer lists paper sizes and this is not one |
262
+ | `tray` | `string?` | Tray name from `TrayInfo.name` (e.g. `"Tray 1"`). Rejects with `unsupported_value` if the printer lists trays and this is not one |
263
+ | `mediaType` | `string?` | Stock type from `PrinterInfo.mediaTypes` (e.g. `"Bond"`). Rejects with `unsupported_value` if the printer lists stock types and this is not one; rejected for `raw`/`html` content and network targets |
264
+ | `scaling` | `'actual' \| 'fit' \| 'shrink-to-fit'?` | How content is sized against the media. **Default `'actual'`** — see below |
265
+ | `scalePercent` | `number?` | Explicit scale, 10–400. Overrides `scaling` when both are sent |
266
+ | `margins` | `Margins?` | Inset from the physical sheet edge. `{ top?, right?, bottom?, left?, units }`, `units` required |
267
+ | `anchor` | `'center' \| 'top-left'?` | Where content sits in the margin box. Default `'center'` |
255
268
 
256
269
  **`data`** — `PrintData[]`
257
270
 
@@ -265,6 +278,102 @@ Each item in the array is one unit of content (page, label, etc.).
265
278
  | `data` | `string` | The content, encoded per `flavor` |
266
279
  | `options` | `Record<string, unknown>?` | Format-specific options forwarded to the agent renderer |
267
280
 
281
+ #### Scaling, margins and anchoring
282
+
283
+ These four settings control where content lands on the sheet. They apply to
284
+ `pdf` and `image` content sent to a named printer.
285
+
286
+ **The default is actual size.** Content prints at its intrinsic size, centred,
287
+ with no scaling applied:
288
+
289
+ ```ts
290
+ // A 4x6 label prints 4x6, wherever the sheet is bigger.
291
+ await ep.print({ printer: 'Office Laser' }, [label])
292
+ ```
293
+
294
+ **This changed in 2.0, and it changes output.** Windows previously stretched
295
+ every page to fill the printable area and had no actual-size path at all. If a
296
+ job depended on that stretch, the nearest equivalent is:
297
+
298
+ ```ts
299
+ await ep.print({ printer: 'Office Laser', scaling: 'fit' }, [doc])
300
+ ```
301
+
302
+ `'fit'` scales to fill the margin box **preserving aspect ratio**, which the
303
+ old Windows path did not — it stretched to fill the printable area regardless
304
+ of shape. Any unprintable band changes that area's proportions, an even band as
305
+ much as an uneven one, so even a document matching its media is slightly
306
+ letterboxed under `'fit'` where it used to be stretched to fill exactly.
307
+ `'shrink-to-fit'` is actual size that scales down only when content overflows —
308
+ it never scales up.
309
+
310
+ **Margins are measured from the physical sheet edge**, not from the printable
311
+ area, so the same numbers mean the same place on paper across printers whose
312
+ unprintable bands differ. Import `inches` or `mm` rather than converting by
313
+ hand:
314
+
315
+ ```ts
316
+ import { inches, mm } from '@gohcltech/edge-print-client'
317
+
318
+ await ep.print(
319
+ { printer: 'Office Laser', margins: inches({ top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 }) },
320
+ [doc],
321
+ )
322
+ ```
323
+
324
+ `units` is required — there is no safe default. A value meant as inches and
325
+ read as points is a near-invisible 1/72" margin.
326
+
327
+ **Set all four edges.** An omitted edge is a deliberate zero, not "leave this
328
+ one alone" — and zero is below the unprintable band on most real printers, so a
329
+ partial object is usually *rejected*:
330
+
331
+ ```ts
332
+ // Rejected on any printer with a hardware margin:
333
+ // invalid_geometry — margins.right 0.00pt is below the hardware minimum
334
+ // 18.00pt (0.25in) for 'Office Laser'
335
+ await ep.print({ printer: 'Office Laser', margins: inches({ top: 0.5 }) }, [doc])
336
+ ```
337
+
338
+ Sending no `margins` key at all is different again, and always fine — it means
339
+ no margin control rather than four zero margins.
340
+
341
+ An explicit `scalePercent` (10–400) **silently overrides** `scaling` when both
342
+ are sent. It also changes how overflow is treated: content that does not fit
343
+ rejects with `content_overflow` under an explicit `scalePercent`, where without
344
+ one it is scaled down and reports a `content_scaled_to_fit` warning instead.
345
+
346
+ What "does not fit" is measured against depends on whether you sent `margins`.
347
+ With them, it is the margin box. Without, it is the whole physical sheet —
348
+ because with no margins the box is the hardware printable area, which an
349
+ ordinary Letter page on a Letter queue already exceeds on any printer with an
350
+ unprintable edge.
351
+
352
+ **Where these are refused rather than ignored.** A setting that provably cannot
353
+ work on the requested path is rejected, because silently dropping it is the
354
+ failure this release exists to remove:
355
+
356
+ | Situation | Code |
357
+ |---|---|
358
+ | `raw` or `html` content, or a network target | `unsupported_for_path` |
359
+ | The PDF is encrypted (its pages cannot be re-composed) | `encrypted_pdf` |
360
+ | `scalePercent` outside 10–400 | `invalid_geometry`, `field: 'scalePercent'` |
361
+ | A negative margin edge | `invalid_geometry`, `field: 'margins.top'` etc. |
362
+ | A margin inside the printer's unprintable band | `invalid_geometry`, naming the hardware minimum |
363
+
364
+ **All of this needs a measurable sheet.** Margins are placed from the sheet
365
+ edge, the hardware minimum is checked against the unprintable band, and
366
+ overflow is judged against the sheet — so the agent has to ask the queue for
367
+ its geometry. Windows reads it locally; macOS asks over IPP, and a queue that
368
+ will not answer yields nothing. The placement rules are then skipped and the
369
+ document goes to the printer as supplied, logged by the agent but carrying no
370
+ warning on the job.
371
+
372
+ Developer Printers skip the *path* checks above, since their job is to show
373
+ what was asked for. They are not exempt from the rules that need no printer: an
374
+ encrypted PDF, an out-of-range `scalePercent` and a negative margin edge all
375
+ still reject.
376
+
268
377
  #### Image sizing
269
378
 
270
379
  An image prints at its **intrinsic physical size**, taken from the resolution
@@ -276,10 +385,14 @@ resolution metadata prints at 12.5 x 6.25 inches rather than being sized to the
276
385
  paper. Send a file that declares its resolution if you need a specific size.
277
386
 
278
387
  An image too large for the sheet is scaled down to fit and the job reports a
279
- `content_scaled_to_fit` warning; nothing is silently cropped.
280
-
281
- This describes Windows. On macOS the image is handed to the system print
282
- system, which fits it to the page instead — the two converge in a later release.
388
+ `content_scaled_to_fit` warning, so nothing is silently cropped — **as long as
389
+ the agent could measure the sheet.** That means asking the queue for its
390
+ geometry, which Windows reads locally and macOS asks for over IPP. A macOS
391
+ queue that will not answer skips the overflow rule along with the margin rules,
392
+ and an oversized image is clipped instead, with no warning on the job because
393
+ nothing was decided. Worth knowing on macOS specifically: CUPS used to fit an
394
+ image to the page by itself, and does not do that for a PDF, which is what an
395
+ image becomes on the way to the spooler in 2.0.
283
396
 
284
397
  #### Print examples
285
398
 
@@ -301,6 +414,18 @@ await ep.print(
301
414
  { printer: 'Office Laser', paperSize: 'Letter' },
302
415
  [{ type: 'pixel', format: 'image', flavor: 'file', data: 'C:\\Reports\\invoice.png' }],
303
416
  )
417
+
418
+ // Actual size, half-inch margins, anchored top-left, on card stock
419
+ await ep.print(
420
+ {
421
+ printer: 'Office Laser',
422
+ scaling: 'actual',
423
+ margins: inches({ top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 }),
424
+ anchor: 'top-left',
425
+ mediaType: 'Bond',
426
+ },
427
+ [{ type: 'pixel', format: 'pdf', flavor: 'base64', data: pdfBase64 }],
428
+ )
304
429
  ```
305
430
 
306
431
  ---
@@ -427,11 +552,14 @@ Sent by the agent:
427
552
  | `not_authenticated` | A request was sent before `connect()` finished authenticating |
428
553
  | `invalid_token` | The token is unknown, or still awaiting approval in the agent UI |
429
554
  | `invalid_request` | The agent could not parse the request |
555
+ | `unsupported_value` | `paperSize`, `tray` or `mediaType` names a value the printer does not list. Carries `supported` |
430
556
  | `unsupported_capability` | A capability the printer explicitly reports it lacks |
431
557
  | `invalid_geometry` | A scale or margin outside what the protocol accepts |
432
- | `unsupported_for_path` | A setting that cannot be honoured on the requested path |
433
- | `printer_not_found` | `defaultPrinter()` found no default configured on the machine |
434
- | `print_failed` | The backend failed after the job was accepted and recorded |
558
+ | `unsupported_for_path` | Geometry or `mediaType` on a path that cannot honour it — `field` is `mediaType`, absent for geometry |
559
+ | `content_overflow` | Content exceeds the margin box under an explicit `scalePercent` |
560
+ | `encrypted_pdf` | Scaling, margins or anchoring were asked for on an encrypted PDF |
561
+ | `printer_not_found` | `defaultPrinter()` found no default configured on the machine. **Not** raised for a `print()` to a missing printer — see below |
562
+ | `print_failed` | The backend failed after the job was accepted and recorded — including a `printer` naming a queue that no longer exists |
435
563
  | `internal_error` | The agent itself failed — not the caller's doing |
436
564
 
437
565
  Raised locally by this library, never sent by the agent:
@@ -448,19 +576,25 @@ Raised locally by this library, never sent by the agent:
448
576
  cannot parse, and here for a `config` or `data` value that cannot be serialised
449
577
  to JSON at all — a circular reference, say. Neither is retryable.
450
578
 
451
- The four codes in the table above are never accepted *from* the agent. A frame
452
- claiming `code: "timeout"` is read as `unknown`, so a failure the agent
453
- considers final cannot arrive labelled retryable.
579
+ Four of those five — `not_connected`, `unreachable`, `connection_closed` and
580
+ `timeout` — are never accepted *from* the agent. A frame claiming
581
+ `code: "timeout"` is read as `unknown`, so a failure the agent considers final
582
+ cannot arrive labelled retryable. `protocol_mismatch` is the exception: it
583
+ belongs to the agent's own set and would be accepted off the wire, but nothing
584
+ in the agent raises it, so in practice it only ever comes from here.
585
+
586
+ **A print to a printer that no longer exists arrives as `print_failed`, not
587
+ `printer_not_found`.** Neither platform backend can separate a missing queue
588
+ from any other spooling failure, so an app that caches printer names cannot
589
+ detect a stale one by code alone — `printer_not_found` covers only the
590
+ `defaultPrinter()` lookup. Re-read `printers()` when a `print_failed` looks
591
+ like it might be a vanished queue.
454
592
 
455
593
  `unsupported_value` is raised when `paperSize`, `tray` or `mediaType` names a
456
594
  value the printer does not list, and it is the one code that populates
457
595
  `supported` — read that array rather than parsing the message, and offer the
458
596
  values it names.
459
597
 
460
- Two more are still **reserved**: `content_overflow` and `encrypted_pdf` are part
461
- of the closed set and typed here, so code written today handles them, but no
462
- agent sends them yet.
463
-
464
598
  And `unknown`, the fallback for a code this client does not recognise — which
465
599
  is what lets a client built today keep working against a future agent. Treat an
466
600
  unfamiliar code as a plain failure rather than a bug.
@@ -499,7 +633,7 @@ The client does not reconnect automatically after a connection drop. A simple
499
633
  exponential-backoff reconnect loop:
500
634
 
501
635
  ```ts
502
- import ep, { ConnectOptions } from '@gohcltech/edge-print-client'
636
+ import ep, { type ConnectOptions } from '@gohcltech/edge-print-client'
503
637
 
504
638
  async function connectWithBackoff(opts: ConnectOptions, maxMs = 30_000) {
505
639
  let delay = 1_000
@@ -26,6 +26,61 @@ export type PrinterTarget = string | {
26
26
  host: string;
27
27
  port: number;
28
28
  };
29
+ /**
30
+ * How content is sized relative to the media.
31
+ *
32
+ * - `'actual'` — the document's intrinsic size, 1:1. The default.
33
+ * - `'fit'` — scaled to fill the margin box, preserving aspect ratio.
34
+ * - `'shrink-to-fit'` — actual size, scaled down only when content overflows.
35
+ */
36
+ export type ScalingMode = 'actual' | 'fit' | 'shrink-to-fit';
37
+ /**
38
+ * Where content sits inside the margin box when it does not fill it.
39
+ * Defaults to `'center'`.
40
+ */
41
+ export type ContentAnchor = 'center' | 'top-left';
42
+ /** The unit a {@link Margins} value is expressed in. */
43
+ export type MarginUnits = 'pt' | 'in' | 'mm';
44
+ /**
45
+ * Margins measured inward from the **physical sheet edge** — not from the
46
+ * printable area, so the same numbers describe the same place on paper
47
+ * whatever unprintable band a given printer has.
48
+ *
49
+ * Every edge is optional, but **set all four in practice.** An omitted edge is
50
+ * a deliberate zero rather than "leave this one alone", and zero falls inside
51
+ * the unprintable band on most real printers — so a partial object is usually
52
+ * rejected with `invalid_geometry` naming an edge you never set. Omitting the
53
+ * whole `margins` key is different, and always fine: it means no margin
54
+ * control at all.
55
+ *
56
+ * Each edge must be zero or greater, or the job rejects with
57
+ * `invalid_geometry` naming it — `margins.top`, `margins.right`,
58
+ * `margins.bottom`, `margins.left`. A margin inside the printer's unprintable
59
+ * band is refused with the same code, naming both the requested value and the
60
+ * hardware minimum.
61
+ *
62
+ * `units` is required here. There is no safe default: a value meant as inches
63
+ * and read as points is a near-invisible ~1/72" margin. Use {@link inches} or
64
+ * {@link mm} to say it once. (The agent itself defaults to points when a
65
+ * hand-rolled client omits the field; this type declines to rely on that.)
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const margins = inches({ top: 0.5, right: 0.5, bottom: 0.5, left: 0.5 })
70
+ * await ep.print({ printer: 'Office Laser', margins }, data)
71
+ * ```
72
+ */
73
+ export interface Margins {
74
+ top?: number;
75
+ right?: number;
76
+ bottom?: number;
77
+ left?: number;
78
+ units: MarginUnits;
79
+ }
80
+ /** Builds {@link Margins} in millimetres. */
81
+ export declare const mm: (m: Omit<Margins, "units">) => Margins;
82
+ /** Builds {@link Margins} in inches. */
83
+ export declare const inches: (m: Omit<Margins, "units">) => Margins;
29
84
  /**
30
85
  * Configuration options for a print job.
31
86
  *
@@ -81,11 +136,59 @@ export interface PrintConfig {
81
136
  * (e.g. `"Plain"`, `"Bond"`, `"Thermal"`). Changes how the printer feeds and
82
137
  * fuses the page. Omit to use the printer's current default.
83
138
  *
139
+ * Rejects with `unsupported_value` when the printer lists stock types and
140
+ * this is not one of them — the error's `supported` array names the real
141
+ * ones. A printer that lists none accepts anything, with a
142
+ * `capability_unverified` warning. Matching ignores case.
143
+ *
84
144
  * Not supported for `raw` or `html` content, or a network target — those
85
145
  * paths never reach the printer's driver. Requesting it there rejects with
86
- * `unsupported_for_path`.
146
+ * `unsupported_for_path` and a `field` of `mediaType`.
87
147
  */
88
148
  mediaType?: string;
149
+ /**
150
+ * How content is sized relative to the media. Defaults to `'actual'`.
151
+ *
152
+ * **This default changed in 2.0.** Windows previously stretched every page
153
+ * to fill the printable area and had no actual-size path at all; a job that
154
+ * relied on that stretch needs `scaling: 'fit'` to keep it.
155
+ *
156
+ * `scaling`, {@link PrintConfig.scalePercent}, {@link PrintConfig.margins}
157
+ * and {@link PrintConfig.anchor} are refused rather than silently dropped on
158
+ * paths that cannot honour them: `raw` or `html` content, and network
159
+ * targets, all reject with `unsupported_for_path`. Asking for any of them on
160
+ * an encrypted PDF rejects with `encrypted_pdf`, since honouring them means
161
+ * re-composing pages the agent cannot open. Developer Printers skip the path
162
+ * check only — the encrypted-PDF and range rules still apply to them.
163
+ */
164
+ scaling?: ScalingMode;
165
+ /**
166
+ * Explicit scale percentage, 10–400 inclusive. Outside that range rejects
167
+ * with `invalid_geometry` and a `field` of `scalePercent`.
168
+ *
169
+ * **Overrides {@link PrintConfig.scaling} when both are present**, silently
170
+ * rather than erroring.
171
+ *
172
+ * It also changes what happens to oversized content. Content that does not
173
+ * fit rejects with `content_overflow` under an explicit `scalePercent`;
174
+ * without one, it is scaled down and the job reports a
175
+ * `content_scaled_to_fit` warning instead of failing.
176
+ *
177
+ * What it must fit depends on {@link PrintConfig.margins}: the margin box
178
+ * when margins were sent, the whole physical sheet when they were not.
179
+ */
180
+ scalePercent?: number;
181
+ /**
182
+ * Margins measured inward from the physical sheet edge. Omit entirely for no
183
+ * margin control; present with an edge omitted makes that edge a deliberate
184
+ * zero.
185
+ */
186
+ margins?: Margins;
187
+ /**
188
+ * Where content sits inside the margin box when it does not fill it.
189
+ * Defaults to `'center'`.
190
+ */
191
+ anchor?: ContentAnchor;
89
192
  }
90
193
  /**
91
194
  * A single unit of content to be printed.
@@ -136,10 +239,6 @@ export interface PrintData {
136
239
  *
137
240
  * An image too large for the sheet is scaled down to fit, and the job
138
241
  * reports a `content_scaled_to_fit` warning.
139
- *
140
- * This applies on Windows. On macOS the image is handed to the system print
141
- * system, which fits it to the page instead; the two converge in a later
142
- * release.
143
242
  */
144
243
  format: 'command' | 'pdf' | 'html' | 'image';
145
244
  /**
@@ -16,6 +16,10 @@
16
16
  * )
17
17
  * ```
18
18
  */
19
+ /** Builds {@link Margins} in millimetres. */
20
+ export const mm = (m) => ({ ...m, units: 'mm' });
21
+ /** Builds {@link Margins} in inches. */
22
+ export const inches = (m) => ({ ...m, units: 'in' });
19
23
  /**
20
24
  * Every failure code this client understands.
21
25
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gohcltech/edge-print-client",
3
- "version": "2.0.81-develop",
3
+ "version": "2.0.89-develop",
4
4
  "description": "Browser client for the Edge Printing WebSocket agent",
5
5
  "license": "MIT",
6
6
  "repository": {