@thermal-label/brother-ql-core 0.3.0 → 0.5.0

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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/data/devices.json +823 -0
  3. package/data/media.json +823 -0
  4. package/dist/__tests__/devices.test.js +112 -31
  5. package/dist/__tests__/devices.test.js.map +1 -1
  6. package/dist/__tests__/media.test.js +251 -1
  7. package/dist/__tests__/media.test.js.map +1 -1
  8. package/dist/__tests__/protocol.test.js +168 -1
  9. package/dist/__tests__/protocol.test.js.map +1 -1
  10. package/dist/__tests__/status.test.js +71 -0
  11. package/dist/__tests__/status.test.js.map +1 -1
  12. package/dist/devices.d.ts +13 -270
  13. package/dist/devices.d.ts.map +1 -1
  14. package/dist/devices.generated.d.ts +696 -0
  15. package/dist/devices.generated.d.ts.map +1 -0
  16. package/dist/devices.generated.js +831 -0
  17. package/dist/devices.generated.js.map +1 -0
  18. package/dist/devices.js +28 -272
  19. package/dist/devices.js.map +1 -1
  20. package/dist/index.d.ts +13 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +10 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/media.d.ts +37 -22
  25. package/dist/media.d.ts.map +1 -1
  26. package/dist/media.generated.d.ts +4 -0
  27. package/dist/media.generated.d.ts.map +1 -0
  28. package/dist/media.generated.js +1640 -0
  29. package/dist/media.generated.js.map +1 -0
  30. package/dist/media.js +74 -281
  31. package/dist/media.js.map +1 -1
  32. package/dist/protocol.d.ts +54 -3
  33. package/dist/protocol.d.ts.map +1 -1
  34. package/dist/protocol.js +117 -18
  35. package/dist/protocol.js.map +1 -1
  36. package/dist/status.d.ts +4 -1
  37. package/dist/status.d.ts.map +1 -1
  38. package/dist/status.js +6 -2
  39. package/dist/status.js.map +1 -1
  40. package/dist/types.d.ts +92 -27
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +13 -9
  43. package/src/__tests__/devices.test.ts +122 -32
  44. package/src/__tests__/media.test.ts +287 -1
  45. package/src/__tests__/protocol.test.ts +209 -0
  46. package/src/__tests__/status.test.ts +87 -0
  47. package/src/devices.generated.ts +840 -0
  48. package/src/devices.ts +30 -272
  49. package/src/index.ts +28 -4
  50. package/src/media.generated.ts +1644 -0
  51. package/src/media.ts +86 -282
  52. package/src/protocol.ts +196 -18
  53. package/src/status.ts +10 -3
  54. package/src/types.ts +93 -27
package/src/media.ts CHANGED
@@ -1,287 +1,49 @@
1
- import type { BrotherQLMedia, MediaType } from './types.js';
1
+ import type { PrintEngine } from '@thermal-label/contracts';
2
+ import type { BrotherQLMedia, MediaType, TapeGeometry } from './types.js';
3
+ import { MEDIA } from './media.generated.js';
4
+
5
+ export { MEDIA };
2
6
 
3
7
  /**
4
- * Registry of supported Brother QL consumables.
5
- *
6
- * Entries are keyed by the firmware media id — the same number the
7
- * printer reports in the 32-byte status response. `heightMm` is omitted
8
- * for continuous media (variable length) and set for die-cut labels
9
- * (fixed length).
10
- *
11
- * `palette` is set on multi-ink media (DK-22251, today's only entry) —
12
- * the driver routes those through `renderMultiPlaneImage` and emits the
13
- * second plane in the raster job. Single-ink rolls leave it undefined
14
- * and route through `renderImage` (dithered single-plane).
8
+ * Resolve per-head-family geometry for a media entry against the
9
+ * engine that's about to print it.
15
10
  *
16
- * Rectangular die-cut entries declare `defaultOrientation: 'horizontal'`
17
- * so landscape input auto-rotates to read along the tape feed direction.
18
- * Continuous wide tape leaves the hint undefined users may go either
19
- * way.
20
- *
21
- * `cornerRadiusMm` is informational; previews use it to render the
22
- * actual paper outline. Round die-cut labels set the radius to
23
- * `widthMm / 2` so the rounded rectangle degenerates to a circle.
11
+ * DK entries fall back to the flat `printAreaDots` / `leftMarginPins` /
12
+ * `rightMarginPins` fields on the entry the same values every QL
13
+ * code path read before the head-family split landed. TZe and HSe
14
+ * entries dispatch on `engine.headDots`: 128 picks `geometry.narrow`,
15
+ * anything else picks `geometry.wide`. Throws when the requested head
16
+ * family has no entry (e.g. 36 mm TZe on a 128-dot head, or any HSe
17
+ * on PT-P910BT those engines simply shouldn't reach this call site
18
+ * because `findMediaByDimensions` gates them upstream).
24
19
  */
25
- export const MEDIA: Record<number, BrotherQLMedia> = {
26
- // Continuous length tape
27
- 257: {
28
- id: 257,
29
- name: '12mm continuous',
30
- type: 'continuous',
31
- widthMm: 12,
32
- printAreaDots: 106,
33
- leftMarginPins: 585,
34
- rightMarginPins: 29,
35
- },
36
- 258: {
37
- id: 258,
38
- name: '29mm continuous (DK-22210)',
39
- type: 'continuous',
40
- widthMm: 29,
41
- printAreaDots: 306,
42
- leftMarginPins: 408,
43
- rightMarginPins: 6,
44
- },
45
- 264: {
46
- id: 264,
47
- name: '38mm continuous (DK-22225)',
48
- type: 'continuous',
49
- widthMm: 38,
50
- printAreaDots: 413,
51
- leftMarginPins: 295,
52
- rightMarginPins: 12,
53
- },
54
- 262: {
55
- id: 262,
56
- name: '50mm continuous (DK-22246)',
57
- type: 'continuous',
58
- widthMm: 50,
59
- printAreaDots: 554,
60
- leftMarginPins: 154,
61
- rightMarginPins: 12,
62
- },
63
- 261: {
64
- id: 261,
65
- name: '54mm continuous (DK-22214)',
66
- type: 'continuous',
67
- widthMm: 54,
68
- printAreaDots: 590,
69
- leftMarginPins: 130,
70
- rightMarginPins: 0,
71
- },
72
- 259: {
73
- id: 259,
74
- name: '62mm continuous (DK-22205)',
75
- type: 'continuous',
76
- widthMm: 62,
77
- printAreaDots: 696,
78
- leftMarginPins: 12,
79
- rightMarginPins: 12,
80
- },
81
- 251: {
82
- id: 251,
83
- name: '62mm continuous two-color (DK-22251)',
84
- type: 'continuous',
85
- widthMm: 62,
86
- palette: [
87
- { name: 'black', rgb: [0, 0, 0] },
88
- { name: 'red', rgb: [255, 0, 0] },
89
- ],
90
- printAreaDots: 696,
91
- leftMarginPins: 12,
92
- rightMarginPins: 12,
93
- },
94
- 260: {
95
- id: 260,
96
- name: '102mm continuous (DK-22243)',
97
- type: 'continuous',
98
- widthMm: 102,
99
- printAreaDots: 1164,
100
- leftMarginPins: 76,
101
- rightMarginPins: 56,
102
- },
103
-
104
- // Die-cut labels
105
- 269: {
106
- id: 269,
107
- name: '17×54mm die-cut (DK-11204)',
108
- type: 'die-cut',
109
- widthMm: 17,
110
- heightMm: 54,
111
- defaultOrientation: 'horizontal',
112
- cornerRadiusMm: 3,
113
- printAreaDots: 165,
114
- leftMarginPins: 0,
115
- rightMarginPins: 0,
116
- dieCutMaskedAreaDots: 566,
117
- },
118
- 270: {
119
- id: 270,
120
- name: '17×87mm die-cut (DK-11203)',
121
- type: 'die-cut',
122
- widthMm: 17,
123
- heightMm: 87,
124
- defaultOrientation: 'horizontal',
125
- cornerRadiusMm: 3,
126
- printAreaDots: 165,
127
- leftMarginPins: 0,
128
- rightMarginPins: 0,
129
- dieCutMaskedAreaDots: 956,
130
- },
131
- 370: {
132
- id: 370,
133
- name: '23×23mm die-cut',
134
- type: 'die-cut',
135
- widthMm: 23,
136
- heightMm: 23,
137
- defaultOrientation: 'horizontal',
138
- cornerRadiusMm: 3,
139
- printAreaDots: 236,
140
- leftMarginPins: 0,
141
- rightMarginPins: 0,
142
- dieCutMaskedAreaDots: 202,
143
- },
144
- 271: {
145
- id: 271,
146
- name: '29×90mm die-cut (DK-11201)',
147
- type: 'die-cut',
148
- widthMm: 29,
149
- heightMm: 90,
150
- defaultOrientation: 'horizontal',
151
- cornerRadiusMm: 3,
152
- printAreaDots: 306,
153
- leftMarginPins: 0,
154
- rightMarginPins: 0,
155
- dieCutMaskedAreaDots: 991,
156
- },
157
- 272: {
158
- id: 272,
159
- name: '38×90mm die-cut (DK-11218)',
160
- type: 'die-cut',
161
- widthMm: 38,
162
- heightMm: 90,
163
- defaultOrientation: 'horizontal',
164
- cornerRadiusMm: 3,
165
- printAreaDots: 413,
166
- leftMarginPins: 0,
167
- rightMarginPins: 0,
168
- dieCutMaskedAreaDots: 991,
169
- },
170
- 367: {
171
- id: 367,
172
- name: '39×48mm die-cut (DK-11219)',
173
- type: 'die-cut',
174
- widthMm: 39,
175
- heightMm: 48,
176
- defaultOrientation: 'horizontal',
177
- cornerRadiusMm: 3,
178
- printAreaDots: 425,
179
- leftMarginPins: 0,
180
- rightMarginPins: 0,
181
- dieCutMaskedAreaDots: 495,
182
- },
183
- 374: {
184
- id: 374,
185
- name: '52×29mm die-cut',
186
- type: 'die-cut',
187
- widthMm: 52,
188
- heightMm: 29,
189
- defaultOrientation: 'horizontal',
190
- cornerRadiusMm: 3,
191
- printAreaDots: 578,
192
- leftMarginPins: 0,
193
- rightMarginPins: 0,
194
- dieCutMaskedAreaDots: 271,
195
- },
196
- 274: {
197
- id: 274,
198
- name: '62×29mm die-cut (DK-11209)',
199
- type: 'die-cut',
200
- widthMm: 62,
201
- heightMm: 29,
202
- defaultOrientation: 'horizontal',
203
- cornerRadiusMm: 3,
204
- printAreaDots: 696,
205
- leftMarginPins: 0,
206
- rightMarginPins: 0,
207
- dieCutMaskedAreaDots: 271,
208
- },
209
- 275: {
210
- id: 275,
211
- name: '62×100mm die-cut (DK-11202)',
212
- type: 'die-cut',
213
- widthMm: 62,
214
- heightMm: 100,
215
- defaultOrientation: 'horizontal',
216
- cornerRadiusMm: 3,
217
- printAreaDots: 696,
218
- leftMarginPins: 0,
219
- rightMarginPins: 0,
220
- dieCutMaskedAreaDots: 1109,
221
- },
222
- 365: {
223
- id: 365,
224
- name: '102×51mm die-cut (DK-11240)',
225
- type: 'die-cut',
226
- widthMm: 102,
227
- heightMm: 51,
228
- defaultOrientation: 'horizontal',
229
- cornerRadiusMm: 3,
230
- printAreaDots: 1164,
231
- leftMarginPins: 0,
232
- rightMarginPins: 0,
233
- dieCutMaskedAreaDots: 526,
234
- },
235
- 366: {
236
- id: 366,
237
- name: '102×152mm die-cut (DK-11241)',
238
- type: 'die-cut',
239
- widthMm: 102,
240
- heightMm: 152,
241
- defaultOrientation: 'horizontal',
242
- cornerRadiusMm: 3,
243
- printAreaDots: 1164,
244
- leftMarginPins: 0,
245
- rightMarginPins: 0,
246
- dieCutMaskedAreaDots: 1660,
247
- },
248
- 362: {
249
- id: 362,
250
- name: '12mm Ø die-cut',
251
- type: 'die-cut',
252
- widthMm: 12,
253
- heightMm: 12,
254
- cornerRadiusMm: 6,
255
- printAreaDots: 94,
256
- leftMarginPins: 0,
257
- rightMarginPins: 0,
258
- dieCutMaskedAreaDots: 94,
259
- },
260
- 363: {
261
- id: 363,
262
- name: '24mm Ø die-cut (DK-11221)',
263
- type: 'die-cut',
264
- widthMm: 24,
265
- heightMm: 24,
266
- cornerRadiusMm: 12,
267
- printAreaDots: 236,
268
- leftMarginPins: 0,
269
- rightMarginPins: 0,
270
- dieCutMaskedAreaDots: 236,
271
- },
272
- 273: {
273
- id: 273,
274
- name: '58mm Ø die-cut (DK-11207)',
275
- type: 'die-cut',
276
- widthMm: 58,
277
- heightMm: 58,
278
- cornerRadiusMm: 29,
279
- printAreaDots: 618,
280
- leftMarginPins: 0,
281
- rightMarginPins: 0,
282
- dieCutMaskedAreaDots: 618,
283
- },
284
- };
20
+ export function resolveTapeGeometry(
21
+ media: BrotherQLMedia,
22
+ engine: Pick<PrintEngine, 'headDots'>,
23
+ ): TapeGeometry {
24
+ if (media.tapeSystem === 'dk') {
25
+ if (
26
+ typeof media.printAreaDots !== 'number' ||
27
+ typeof media.leftMarginPins !== 'number' ||
28
+ typeof media.rightMarginPins !== 'number'
29
+ ) {
30
+ throw new Error(`DK media ${media.id.toString()} missing flat geometry fields`);
31
+ }
32
+ return {
33
+ printAreaDots: media.printAreaDots,
34
+ leftMarginPins: media.leftMarginPins,
35
+ rightMarginPins: media.rightMarginPins,
36
+ };
37
+ }
38
+ const family = engine.headDots === 128 ? 'narrow' : 'wide';
39
+ const geometry = media.geometry?.[family];
40
+ if (!geometry) {
41
+ throw new Error(
42
+ `${media.name} not supported on a ${engine.headDots.toString()}-dot head (no \`geometry.${family}\` entry)`,
43
+ );
44
+ }
45
+ return geometry;
46
+ }
285
47
 
286
48
  /**
287
49
  * Default media when `createPreview()` is called without media and
@@ -291,6 +53,22 @@ export const MEDIA: Record<number, BrotherQLMedia> = {
291
53
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DK-22205 is a hard-coded registry key
292
54
  export const DEFAULT_MEDIA: BrotherQLMedia = MEDIA[259]!;
293
55
 
56
+ /**
57
+ * Default media keyed by engine protocol. QL falls back to 62 mm DK
58
+ * continuous (DK-22205, the common starter roll); PT falls back to
59
+ * 12 mm TZe (id 404, the most common PT starter tape).
60
+ */
61
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 12 mm TZe is a hard-coded registry key
62
+ export const DEFAULT_PT_MEDIA: BrotherQLMedia = MEDIA[404]!;
63
+
64
+ /**
65
+ * Pick a default media entry for an engine. Used by `createPreview()`
66
+ * when neither user-supplied media nor a detected roll is available.
67
+ */
68
+ export function defaultMediaForEngine(engine: Pick<PrintEngine, 'protocol'>): BrotherQLMedia {
69
+ return engine.protocol === 'pt-raster' ? DEFAULT_PT_MEDIA : DEFAULT_MEDIA;
70
+ }
71
+
294
72
  export function findMedia(id: number): BrotherQLMedia | undefined {
295
73
  return MEDIA[id];
296
74
  }
@@ -308,21 +86,47 @@ export function findMediaByWidth(widthMm: number, type: MediaType): BrotherQLMed
308
86
  * printer is configured for two-colour media. When
309
87
  * both DK-22205 (259) and DK-22251 (251) match the
310
88
  * dimensions, the flag picks the right one.
89
+ * @param engine optional engine descriptor used to gate the
90
+ * lookup by tape-system and head-family. When
91
+ * omitted, the search is restricted to DK media —
92
+ * legacy QL behaviour. PT-* callers pass the
93
+ * primary engine so the search returns TZe / HSe
94
+ * entries with the right `narrow` / `wide`
95
+ * geometry, and so a 128-dot head never resolves
96
+ * a 36 mm TZe / 31 mm HSe-3:1 width and
97
+ * PT-P910BT (TZe-only) never resolves an HSe
98
+ * entry.
311
99
  */
312
100
  export function findMediaByDimensions(
313
101
  widthMm: number,
314
102
  heightMm: number,
315
103
  twoColorMode = false,
104
+ engine?: Pick<PrintEngine, 'headDots' | 'mediaCompatibility'>,
316
105
  ): BrotherQLMedia | undefined {
106
+ // When no engine is supplied we preserve the original DK-only
107
+ // behaviour so legacy callers don't suddenly resolve TZe or HSe
108
+ // entries on width-only ambiguity (e.g. 12 mm DK-22214 vs 12 mm TZe).
109
+ const tapeSystems: readonly string[] | undefined =
110
+ engine?.mediaCompatibility ?? (engine ? undefined : ['dk']);
111
+ const allowed = (m: BrotherQLMedia): boolean => {
112
+ if (tapeSystems && !tapeSystems.includes(m.tapeSystem)) return false;
113
+ if (engine && m.tapeSystem !== 'dk') {
114
+ // TZe / HSe — require the head-family geometry to exist.
115
+ const family = engine.headDots === 128 ? 'narrow' : 'wide';
116
+ if (!m.geometry?.[family]) return false;
117
+ }
118
+ return true;
119
+ };
120
+
317
121
  if (heightMm === 0) {
318
122
  const continuousMatches = Object.values(MEDIA).filter(
319
- m => m.type === 'continuous' && m.widthMm === widthMm,
123
+ m => m.type === 'continuous' && m.widthMm === widthMm && allowed(m),
320
124
  );
321
125
  if (continuousMatches.length === 0) return undefined;
322
126
  const preferred = continuousMatches.find(m => (m.palette !== undefined) === twoColorMode);
323
127
  return preferred ?? continuousMatches[0];
324
128
  }
325
129
  return Object.values(MEDIA).find(
326
- m => m.type === 'die-cut' && m.widthMm === widthMm && m.heightMm === heightMm,
130
+ m => m.type === 'die-cut' && m.widthMm === widthMm && m.heightMm === heightMm && allowed(m),
327
131
  );
328
132
  }
package/src/protocol.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { getRow, createBitmap } from '@mbtech-nl/bitmap';
2
+ import type { PrintEngine } from '@thermal-label/contracts';
2
3
  import { packBits } from './pack-bits.js';
3
- import type { BrotherQLMedia, PageData, JobOptions, PageOptions } from './types.js';
4
+ import { resolveTapeGeometry } from './media.js';
5
+ import type {
6
+ BrotherEngineCapabilities,
7
+ BrotherQLMedia,
8
+ PageData,
9
+ JobOptions,
10
+ PageOptions,
11
+ TapeGeometry,
12
+ } from './types.js';
4
13
 
5
- export function buildInvalidate(): Uint8Array {
6
- return new Uint8Array(200);
14
+ export function buildInvalidate(byteCount = 200): Uint8Array {
15
+ return new Uint8Array(byteCount);
7
16
  }
8
17
 
9
18
  export function buildStatusRequest(): Uint8Array {
@@ -58,11 +67,12 @@ export function buildExpandedMode(
58
67
  cutAtEnd: boolean,
59
68
  highRes: boolean,
60
69
  twoColor = false,
70
+ highResFlagBit = 0x10,
61
71
  ): Uint8Array {
62
72
  let flags = 0x00;
63
73
  if (twoColor) flags |= 0x01;
64
74
  if (cutAtEnd) flags |= 0x08;
65
- if (highRes) flags |= 0x10;
75
+ if (highRes) flags |= highResFlagBit;
66
76
  return new Uint8Array([0x1b, 0x69, 0x4b, flags]);
67
77
  }
68
78
 
@@ -135,14 +145,133 @@ function concat(...arrays: Uint8Array[]): Uint8Array {
135
145
  return out;
136
146
  }
137
147
 
138
- export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Array {
148
+ /**
149
+ * Per-protocol wire-format constants.
150
+ *
151
+ * QL and PT raster differ in three numeric constants and one rule —
152
+ * everything else (status request, raster opcode, PackBits, two-colour
153
+ * plane encoding) is shared and lives in `encodeRasterJob`. Per the
154
+ * plan §4.2 / §7, these are protocol-internal and do not leak onto
155
+ * the device registry.
156
+ *
157
+ * - `feedMarginDots` — leading/trailing blank tape (`ESC i d`). QL = 35,
158
+ * PT = 14. Per `brother_label/devices.py` and Brother's PT raster
159
+ * manual; verify against print output during phase 4.
160
+ * - `invalidateBytes` — leading invalidate sequence. QL is 200 by
161
+ * default but the encoder bumps it to 400 when the engine carries
162
+ * `capabilities.twoColor`. PT is always 200 (no two-colour PT model
163
+ * exists today).
164
+ * - `highResFlagBit` — bit set in `ESC i K` flags when `highRes`
165
+ * is requested. QL uses bit 4 (0x10) for 300x600; PT uses bit 6
166
+ * (0x40) for 180x360 / 360x720 (per nbuchwitz/ptouch).
167
+ * - `duplicateRasterLines` — when `highRes` is on, PT requires each
168
+ * raster line to be sent twice. QL's high-res mode does not.
169
+ */
170
+ export interface RasterProtocolConfig {
171
+ feedMarginDots: number;
172
+ invalidateBytes: number;
173
+ highResFlagBit: number;
174
+ duplicateRasterLines: boolean;
175
+ }
176
+
177
+ export const QL_PROTOCOL_CONFIG: RasterProtocolConfig = {
178
+ feedMarginDots: 35,
179
+ invalidateBytes: 200,
180
+ highResFlagBit: 0x10,
181
+ duplicateRasterLines: false,
182
+ };
183
+
184
+ export const PT_PROTOCOL_CONFIG: RasterProtocolConfig = {
185
+ feedMarginDots: 14,
186
+ invalidateBytes: 200,
187
+ highResFlagBit: 0x40,
188
+ duplicateRasterLines: true,
189
+ };
190
+
191
+ /** Engine shape consumed by the encoder — narrow `Pick` so unit tests can synthesise minimal stubs. */
192
+ export type EncoderEngine = Pick<PrintEngine, 'protocol' | 'headDots'> & {
193
+ capabilities?: BrotherEngineCapabilities;
194
+ };
195
+
196
+ /**
197
+ * Cutter compression-required quirk.
198
+ *
199
+ * PT-E550W silently fails to cut when compression is disabled —
200
+ * documented in nbuchwitz/ptouch:PTE550W ("E550W requires compression
201
+ * ON for cutting to work"). Encoded as a per-name guard rather than a
202
+ * registry capability so we don't promote a one-model bug into the
203
+ * data shape.
204
+ */
205
+ const COMPRESSION_REQUIRED_FOR_CUTTER = new Set(['PT-E550W']);
206
+
207
+ function maybeCheckCutterCompressionQuirk(
208
+ deviceName: string | undefined,
209
+ autoCut: boolean,
210
+ compress: boolean,
211
+ ): void {
212
+ if (autoCut && !compress && deviceName && COMPRESSION_REQUIRED_FOR_CUTTER.has(deviceName)) {
213
+ throw new Error(
214
+ `${deviceName} requires compression to be enabled when autocut is on (per nbuchwitz/ptouch)`,
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Resolve per-page geometry for the encoder.
221
+ *
222
+ * QL paths take the flat fields off the media row directly; PT paths
223
+ * delegate to `resolveTapeGeometry` so the head-family dispatch lives
224
+ * in one place. `engine` is required for PT and ignored for QL.
225
+ */
226
+ function resolveEncoderGeometry(
227
+ media: BrotherQLMedia,
228
+ engine: EncoderEngine | undefined,
229
+ ): TapeGeometry {
230
+ if (media.tapeSystem === 'dk') {
231
+ if (
232
+ typeof media.printAreaDots !== 'number' ||
233
+ typeof media.leftMarginPins !== 'number' ||
234
+ typeof media.rightMarginPins !== 'number'
235
+ ) {
236
+ throw new Error(`DK media ${media.id.toString()} missing flat geometry fields`);
237
+ }
238
+ return {
239
+ printAreaDots: media.printAreaDots,
240
+ leftMarginPins: media.leftMarginPins,
241
+ rightMarginPins: media.rightMarginPins,
242
+ };
243
+ }
244
+ if (!engine) {
245
+ throw new Error(
246
+ `tape system "${media.tapeSystem}" requires an engine to resolve head-family geometry`,
247
+ );
248
+ }
249
+ return resolveTapeGeometry(media, engine);
250
+ }
251
+
252
+ interface EncodeContext {
253
+ config: RasterProtocolConfig;
254
+ engine?: EncoderEngine | undefined;
255
+ deviceName?: string | undefined;
256
+ }
257
+
258
+ function encodeRasterJob(pages: PageData[], options: JobOptions, ctx: EncodeContext): Uint8Array {
259
+ const { config, engine, deviceName } = ctx;
139
260
  const copies = options.copies ?? 1;
140
261
  const chunks: Uint8Array[] = [];
141
262
 
142
- // Python brother_ql sequence: raster-mode first, then 200-byte invalidate, then init, then
263
+ // Two-colour invalidate-byte derivation (§7.1): QL bumps to 400 when
264
+ // the engine carries `twoColor`. PT has no two-colour models today;
265
+ // its config keeps invalidateBytes at 200 regardless.
266
+ const baseInvalidate = config.invalidateBytes;
267
+ const twoColorInvalidateBoost =
268
+ engine?.protocol === 'ql-raster' && engine.capabilities?.twoColor === true;
269
+ const invalidateBytes = twoColorInvalidateBoost ? baseInvalidate * 2 : baseInvalidate;
270
+
271
+ // Python brother_ql sequence: raster-mode first, then invalidate, then init, then
143
272
  // raster-mode again (matches observed working sequence for QL-820NWB).
144
273
  chunks.push(buildRasterMode());
145
- chunks.push(buildInvalidate());
274
+ chunks.push(buildInvalidate(invalidateBytes));
146
275
  chunks.push(buildInitialize());
147
276
 
148
277
  const allPageInstances: PageData[] = [];
@@ -158,10 +287,23 @@ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Arr
158
287
  const autoCut = opts.autoCut ?? true;
159
288
  const cutAtEnd = opts.cutAtEnd ?? true;
160
289
  const highRes = opts.highResolution ?? false;
161
- const marginDots = opts.marginDots ?? 35;
162
290
  const compress = opts.compress ?? false;
163
291
  const { bitmap, media } = page;
164
292
 
293
+ // High-res mode requires the engine to declare `capabilities.highResDpi`.
294
+ if (highRes && engine && engine.capabilities?.highResDpi === undefined) {
295
+ throw new Error(
296
+ `${deviceName ?? 'device'} does not support high-res mode (engine.capabilities.highResDpi is not set)`,
297
+ );
298
+ }
299
+
300
+ maybeCheckCutterCompressionQuirk(deviceName, autoCut, compress);
301
+
302
+ // Per §7.3: PT high-res doubles the feed margin and duplicates
303
+ // each raster line. QL high-res leaves both untouched.
304
+ const baseMargin = opts.marginDots ?? config.feedMarginDots;
305
+ const marginDots = config.duplicateRasterLines && highRes ? baseMargin * 2 : baseMargin;
306
+
165
307
  // Multi-ink media (e.g. DK-22251) requires two-color mode even for black-only jobs.
166
308
  // Auto-create an empty red plane when the tape demands it but caller didn't supply one.
167
309
  const multiInk = media.palette !== undefined;
@@ -182,34 +324,41 @@ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Arr
182
324
  chunks.push(buildPrintInfo(media, rowCount, i));
183
325
  chunks.push(buildVariousMode(autoCut));
184
326
  chunks.push(buildCutEach(1));
185
- chunks.push(buildExpandedMode(cutAtEnd, highRes, twoColor));
327
+ chunks.push(buildExpandedMode(cutAtEnd, highRes, twoColor, config.highResFlagBit));
186
328
  chunks.push(buildMargin(marginDots));
187
329
  if (compress) chunks.push(buildCompression(true));
188
330
 
189
331
  // Each raster row must cover the full print head width (derived from media geometry).
190
- // leftMarginPins + printAreaDots + rightMarginPins = head pin count (720 or 1296).
191
- const totalPins = media.leftMarginPins + media.printAreaDots + media.rightMarginPins;
332
+ // leftMarginPins + printAreaDots + rightMarginPins = head pin count (720 / 1296 / 128 / 560).
333
+ const geometry = resolveEncoderGeometry(media, engine);
334
+ const leftMarginPins = geometry.leftMarginPins;
335
+ const totalPins = leftMarginPins + geometry.printAreaDots + geometry.rightMarginPins;
192
336
  const rowByteLen = Math.ceil(totalPins / 8);
193
337
 
194
338
  // Rows interleaved per raster line (matches Python brother_ql behaviour).
195
339
  // Two-color: black row then red row for each line. Single-color: black only.
196
340
  // When `compress` is on, each row's bytes are PackBits-encoded and the
197
- // raster-row LEN byte carries the compressed length. The printer was
198
- // already switched into compression mode by `buildCompression(true)`
199
- // above, so it expects every subsequent row to be PackBits.
341
+ // raster-row LEN byte carries the compressed length.
342
+ //
343
+ // Per §7.3: PT high-res duplicates each raster line. QL doesn't.
344
+ const duplicate = config.duplicateRasterLines && highRes;
200
345
  for (let r = 0; r < rowCount; r++) {
201
346
  const blackSrc = getRow(bitmap, r);
202
347
  const blackBytes = new Uint8Array(rowByteLen);
203
- placeBits(blackSrc, bitmap.widthPx, blackBytes, media.leftMarginPins);
348
+ placeBits(blackSrc, bitmap.widthPx, blackBytes, leftMarginPins);
204
349
  const blackPayload = compress ? packBits(blackBytes) : blackBytes;
205
- chunks.push(buildRasterRow(blackPayload, 'black', twoColor));
350
+ const blackChunk = buildRasterRow(blackPayload, 'black', twoColor);
351
+ chunks.push(blackChunk);
352
+ if (duplicate) chunks.push(blackChunk);
206
353
 
207
354
  if (twoColor && redBitmap !== undefined) {
208
355
  const redSrc = getRow(redBitmap, r);
209
356
  const redBytes = new Uint8Array(rowByteLen);
210
- placeBits(redSrc, redBitmap.widthPx, redBytes, media.leftMarginPins);
357
+ placeBits(redSrc, redBitmap.widthPx, redBytes, leftMarginPins);
211
358
  const redPayload = compress ? packBits(redBytes) : redBytes;
212
- chunks.push(buildRasterRow(redPayload, 'red', twoColor));
359
+ const redChunk = buildRasterRow(redPayload, 'red', twoColor);
360
+ chunks.push(redChunk);
361
+ if (duplicate) chunks.push(redChunk);
213
362
  }
214
363
  }
215
364
 
@@ -218,3 +367,32 @@ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Arr
218
367
 
219
368
  return concat(...chunks);
220
369
  }
370
+
371
+ /**
372
+ * Encode a QL job. Public legacy entry point — DK media only, no
373
+ * engine awareness, two-colour invalidate-byte boost not applied.
374
+ * Use `encodeJobForEngine` for PT or for QL with two-colour invalidate
375
+ * derivation from `engine.capabilities.twoColor`.
376
+ */
377
+ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Array {
378
+ return encodeRasterJob(pages, options, { config: QL_PROTOCOL_CONFIG });
379
+ }
380
+
381
+ /**
382
+ * Encode a job for a specific engine. Dispatches on `engine.protocol`:
383
+ * `'ql-raster'` picks the QL config, `'pt-raster'` picks the PT config
384
+ * and threads `engine.headDots` through to head-family geometry
385
+ * resolution for TZe / HSe media.
386
+ *
387
+ * `deviceName` is optional; when supplied, it enables the per-name
388
+ * cutter-compression guard for PT-E550W (§7.2 / §12.12).
389
+ */
390
+ export function encodeJobForEngine(
391
+ pages: PageData[],
392
+ options: JobOptions,
393
+ engine: EncoderEngine,
394
+ deviceName?: string,
395
+ ): Uint8Array {
396
+ const config = engine.protocol === 'pt-raster' ? PT_PROTOCOL_CONFIG : QL_PROTOCOL_CONFIG;
397
+ return encodeRasterJob(pages, options, { config, engine, deviceName });
398
+ }