@thermal-label/brother-ql-core 0.0.1 → 0.3.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 (68) hide show
  1. package/dist/__tests__/devices.test.js +16 -2
  2. package/dist/__tests__/devices.test.js.map +1 -1
  3. package/dist/__tests__/media.test.js +37 -6
  4. package/dist/__tests__/media.test.js.map +1 -1
  5. package/dist/__tests__/pack-bits.test.d.ts +2 -0
  6. package/dist/__tests__/pack-bits.test.d.ts.map +1 -0
  7. package/dist/__tests__/pack-bits.test.js +90 -0
  8. package/dist/__tests__/pack-bits.test.js.map +1 -0
  9. package/dist/__tests__/preview.test.d.ts +2 -0
  10. package/dist/__tests__/preview.test.d.ts.map +1 -0
  11. package/dist/__tests__/preview.test.js +41 -0
  12. package/dist/__tests__/preview.test.js.map +1 -0
  13. package/dist/__tests__/protocol.test.js +46 -1
  14. package/dist/__tests__/protocol.test.js.map +1 -1
  15. package/dist/__tests__/status.test.js +28 -22
  16. package/dist/__tests__/status.test.js.map +1 -1
  17. package/dist/devices.d.ts +40 -21
  18. package/dist/devices.d.ts.map +1 -1
  19. package/dist/devices.js +38 -20
  20. package/dist/devices.js.map +1 -1
  21. package/dist/index.d.ts +8 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +5 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/media.d.ts +43 -4
  26. package/dist/media.d.ts.map +1 -1
  27. package/dist/media.js +92 -24
  28. package/dist/media.js.map +1 -1
  29. package/dist/orientation.d.ts +11 -0
  30. package/dist/orientation.d.ts.map +1 -0
  31. package/dist/orientation.js +10 -0
  32. package/dist/orientation.js.map +1 -0
  33. package/dist/pack-bits.d.ts +20 -0
  34. package/dist/pack-bits.d.ts.map +1 -0
  35. package/dist/pack-bits.js +61 -0
  36. package/dist/pack-bits.js.map +1 -0
  37. package/dist/preview.d.ts +13 -0
  38. package/dist/preview.d.ts.map +1 -0
  39. package/dist/preview.js +31 -0
  40. package/dist/preview.js.map +1 -0
  41. package/dist/protocol.d.ts +2 -2
  42. package/dist/protocol.d.ts.map +1 -1
  43. package/dist/protocol.js +14 -8
  44. package/dist/protocol.js.map +1 -1
  45. package/dist/status.d.ts +20 -2
  46. package/dist/status.d.ts.map +1 -1
  47. package/dist/status.js +59 -45
  48. package/dist/status.js.map +1 -1
  49. package/dist/types.d.ts +46 -30
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +3 -2
  54. package/src/__tests__/devices.test.ts +18 -2
  55. package/src/__tests__/media.test.ts +39 -6
  56. package/src/__tests__/pack-bits.test.ts +92 -0
  57. package/src/__tests__/preview.test.ts +52 -0
  58. package/src/__tests__/protocol.test.ts +47 -1
  59. package/src/__tests__/status.test.ts +31 -22
  60. package/src/devices.ts +41 -22
  61. package/src/index.ts +45 -11
  62. package/src/media.ts +105 -27
  63. package/src/orientation.ts +11 -0
  64. package/src/pack-bits.ts +64 -0
  65. package/src/preview.ts +35 -0
  66. package/src/protocol.ts +16 -9
  67. package/src/status.ts +62 -47
  68. package/src/types.ts +52 -32
package/src/media.ts CHANGED
@@ -1,13 +1,34 @@
1
- import { type MediaDescriptor, type MediaType } from './types.js';
1
+ import type { BrotherQLMedia, MediaType } from './types.js';
2
2
 
3
- export const MEDIA: Record<number, MediaDescriptor> = {
3
+ /**
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).
15
+ *
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.
24
+ */
25
+ export const MEDIA: Record<number, BrotherQLMedia> = {
4
26
  // Continuous length tape
5
27
  257: {
6
28
  id: 257,
7
29
  name: '12mm continuous',
8
30
  type: 'continuous',
9
31
  widthMm: 12,
10
- lengthMm: 0,
11
32
  printAreaDots: 106,
12
33
  leftMarginPins: 585,
13
34
  rightMarginPins: 29,
@@ -17,7 +38,6 @@ export const MEDIA: Record<number, MediaDescriptor> = {
17
38
  name: '29mm continuous (DK-22210)',
18
39
  type: 'continuous',
19
40
  widthMm: 29,
20
- lengthMm: 0,
21
41
  printAreaDots: 306,
22
42
  leftMarginPins: 408,
23
43
  rightMarginPins: 6,
@@ -27,7 +47,6 @@ export const MEDIA: Record<number, MediaDescriptor> = {
27
47
  name: '38mm continuous (DK-22225)',
28
48
  type: 'continuous',
29
49
  widthMm: 38,
30
- lengthMm: 0,
31
50
  printAreaDots: 413,
32
51
  leftMarginPins: 295,
33
52
  rightMarginPins: 12,
@@ -37,7 +56,6 @@ export const MEDIA: Record<number, MediaDescriptor> = {
37
56
  name: '50mm continuous (DK-22246)',
38
57
  type: 'continuous',
39
58
  widthMm: 50,
40
- lengthMm: 0,
41
59
  printAreaDots: 554,
42
60
  leftMarginPins: 154,
43
61
  rightMarginPins: 12,
@@ -47,7 +65,6 @@ export const MEDIA: Record<number, MediaDescriptor> = {
47
65
  name: '54mm continuous (DK-22214)',
48
66
  type: 'continuous',
49
67
  widthMm: 54,
50
- lengthMm: 0,
51
68
  printAreaDots: 590,
52
69
  leftMarginPins: 130,
53
70
  rightMarginPins: 0,
@@ -57,7 +74,6 @@ export const MEDIA: Record<number, MediaDescriptor> = {
57
74
  name: '62mm continuous (DK-22205)',
58
75
  type: 'continuous',
59
76
  widthMm: 62,
60
- lengthMm: 0,
61
77
  printAreaDots: 696,
62
78
  leftMarginPins: 12,
63
79
  rightMarginPins: 12,
@@ -67,18 +83,19 @@ export const MEDIA: Record<number, MediaDescriptor> = {
67
83
  name: '62mm continuous two-color (DK-22251)',
68
84
  type: 'continuous',
69
85
  widthMm: 62,
70
- lengthMm: 0,
86
+ palette: [
87
+ { name: 'black', rgb: [0, 0, 0] },
88
+ { name: 'red', rgb: [255, 0, 0] },
89
+ ],
71
90
  printAreaDots: 696,
72
91
  leftMarginPins: 12,
73
92
  rightMarginPins: 12,
74
- twoColorTape: true,
75
93
  },
76
94
  260: {
77
95
  id: 260,
78
96
  name: '102mm continuous (DK-22243)',
79
97
  type: 'continuous',
80
98
  widthMm: 102,
81
- lengthMm: 0,
82
99
  printAreaDots: 1164,
83
100
  leftMarginPins: 76,
84
101
  rightMarginPins: 56,
@@ -90,7 +107,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
90
107
  name: '17×54mm die-cut (DK-11204)',
91
108
  type: 'die-cut',
92
109
  widthMm: 17,
93
- lengthMm: 54,
110
+ heightMm: 54,
111
+ defaultOrientation: 'horizontal',
112
+ cornerRadiusMm: 3,
94
113
  printAreaDots: 165,
95
114
  leftMarginPins: 0,
96
115
  rightMarginPins: 0,
@@ -101,7 +120,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
101
120
  name: '17×87mm die-cut (DK-11203)',
102
121
  type: 'die-cut',
103
122
  widthMm: 17,
104
- lengthMm: 87,
123
+ heightMm: 87,
124
+ defaultOrientation: 'horizontal',
125
+ cornerRadiusMm: 3,
105
126
  printAreaDots: 165,
106
127
  leftMarginPins: 0,
107
128
  rightMarginPins: 0,
@@ -112,7 +133,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
112
133
  name: '23×23mm die-cut',
113
134
  type: 'die-cut',
114
135
  widthMm: 23,
115
- lengthMm: 23,
136
+ heightMm: 23,
137
+ defaultOrientation: 'horizontal',
138
+ cornerRadiusMm: 3,
116
139
  printAreaDots: 236,
117
140
  leftMarginPins: 0,
118
141
  rightMarginPins: 0,
@@ -123,7 +146,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
123
146
  name: '29×90mm die-cut (DK-11201)',
124
147
  type: 'die-cut',
125
148
  widthMm: 29,
126
- lengthMm: 90,
149
+ heightMm: 90,
150
+ defaultOrientation: 'horizontal',
151
+ cornerRadiusMm: 3,
127
152
  printAreaDots: 306,
128
153
  leftMarginPins: 0,
129
154
  rightMarginPins: 0,
@@ -134,7 +159,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
134
159
  name: '38×90mm die-cut (DK-11218)',
135
160
  type: 'die-cut',
136
161
  widthMm: 38,
137
- lengthMm: 90,
162
+ heightMm: 90,
163
+ defaultOrientation: 'horizontal',
164
+ cornerRadiusMm: 3,
138
165
  printAreaDots: 413,
139
166
  leftMarginPins: 0,
140
167
  rightMarginPins: 0,
@@ -145,7 +172,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
145
172
  name: '39×48mm die-cut (DK-11219)',
146
173
  type: 'die-cut',
147
174
  widthMm: 39,
148
- lengthMm: 48,
175
+ heightMm: 48,
176
+ defaultOrientation: 'horizontal',
177
+ cornerRadiusMm: 3,
149
178
  printAreaDots: 425,
150
179
  leftMarginPins: 0,
151
180
  rightMarginPins: 0,
@@ -156,7 +185,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
156
185
  name: '52×29mm die-cut',
157
186
  type: 'die-cut',
158
187
  widthMm: 52,
159
- lengthMm: 29,
188
+ heightMm: 29,
189
+ defaultOrientation: 'horizontal',
190
+ cornerRadiusMm: 3,
160
191
  printAreaDots: 578,
161
192
  leftMarginPins: 0,
162
193
  rightMarginPins: 0,
@@ -167,7 +198,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
167
198
  name: '62×29mm die-cut (DK-11209)',
168
199
  type: 'die-cut',
169
200
  widthMm: 62,
170
- lengthMm: 29,
201
+ heightMm: 29,
202
+ defaultOrientation: 'horizontal',
203
+ cornerRadiusMm: 3,
171
204
  printAreaDots: 696,
172
205
  leftMarginPins: 0,
173
206
  rightMarginPins: 0,
@@ -178,7 +211,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
178
211
  name: '62×100mm die-cut (DK-11202)',
179
212
  type: 'die-cut',
180
213
  widthMm: 62,
181
- lengthMm: 100,
214
+ heightMm: 100,
215
+ defaultOrientation: 'horizontal',
216
+ cornerRadiusMm: 3,
182
217
  printAreaDots: 696,
183
218
  leftMarginPins: 0,
184
219
  rightMarginPins: 0,
@@ -189,7 +224,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
189
224
  name: '102×51mm die-cut (DK-11240)',
190
225
  type: 'die-cut',
191
226
  widthMm: 102,
192
- lengthMm: 51,
227
+ heightMm: 51,
228
+ defaultOrientation: 'horizontal',
229
+ cornerRadiusMm: 3,
193
230
  printAreaDots: 1164,
194
231
  leftMarginPins: 0,
195
232
  rightMarginPins: 0,
@@ -200,7 +237,9 @@ export const MEDIA: Record<number, MediaDescriptor> = {
200
237
  name: '102×152mm die-cut (DK-11241)',
201
238
  type: 'die-cut',
202
239
  widthMm: 102,
203
- lengthMm: 152,
240
+ heightMm: 152,
241
+ defaultOrientation: 'horizontal',
242
+ cornerRadiusMm: 3,
204
243
  printAreaDots: 1164,
205
244
  leftMarginPins: 0,
206
245
  rightMarginPins: 0,
@@ -211,7 +250,8 @@ export const MEDIA: Record<number, MediaDescriptor> = {
211
250
  name: '12mm Ø die-cut',
212
251
  type: 'die-cut',
213
252
  widthMm: 12,
214
- lengthMm: 12,
253
+ heightMm: 12,
254
+ cornerRadiusMm: 6,
215
255
  printAreaDots: 94,
216
256
  leftMarginPins: 0,
217
257
  rightMarginPins: 0,
@@ -222,7 +262,8 @@ export const MEDIA: Record<number, MediaDescriptor> = {
222
262
  name: '24mm Ø die-cut (DK-11221)',
223
263
  type: 'die-cut',
224
264
  widthMm: 24,
225
- lengthMm: 24,
265
+ heightMm: 24,
266
+ cornerRadiusMm: 12,
226
267
  printAreaDots: 236,
227
268
  leftMarginPins: 0,
228
269
  rightMarginPins: 0,
@@ -233,7 +274,8 @@ export const MEDIA: Record<number, MediaDescriptor> = {
233
274
  name: '58mm Ø die-cut (DK-11207)',
234
275
  type: 'die-cut',
235
276
  widthMm: 58,
236
- lengthMm: 58,
277
+ heightMm: 58,
278
+ cornerRadiusMm: 29,
237
279
  printAreaDots: 618,
238
280
  leftMarginPins: 0,
239
281
  rightMarginPins: 0,
@@ -241,10 +283,46 @@ export const MEDIA: Record<number, MediaDescriptor> = {
241
283
  },
242
284
  };
243
285
 
244
- export function findMedia(id: number): MediaDescriptor | undefined {
286
+ /**
287
+ * Default media when `createPreview()` is called without media and
288
+ * without a detected roll. 62mm continuous (DK-22205) is the common
289
+ * single-colour shipping roll.
290
+ */
291
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DK-22205 is a hard-coded registry key
292
+ export const DEFAULT_MEDIA: BrotherQLMedia = MEDIA[259]!;
293
+
294
+ export function findMedia(id: number): BrotherQLMedia | undefined {
245
295
  return MEDIA[id];
246
296
  }
247
297
 
248
- export function findMediaByWidth(widthMm: number, type: MediaType): MediaDescriptor[] {
298
+ export function findMediaByWidth(widthMm: number, type: MediaType): BrotherQLMedia[] {
249
299
  return Object.values(MEDIA).filter(m => m.widthMm === widthMm && m.type === type);
250
300
  }
301
+
302
+ /**
303
+ * Match status-response dimensions to a media registry entry.
304
+ *
305
+ * @param widthMm media width in mm (status byte 10)
306
+ * @param heightMm media length in mm (status byte 17) — 0 = continuous
307
+ * @param twoColorMode true when the status response indicates the
308
+ * printer is configured for two-colour media. When
309
+ * both DK-22205 (259) and DK-22251 (251) match the
310
+ * dimensions, the flag picks the right one.
311
+ */
312
+ export function findMediaByDimensions(
313
+ widthMm: number,
314
+ heightMm: number,
315
+ twoColorMode = false,
316
+ ): BrotherQLMedia | undefined {
317
+ if (heightMm === 0) {
318
+ const continuousMatches = Object.values(MEDIA).filter(
319
+ m => m.type === 'continuous' && m.widthMm === widthMm,
320
+ );
321
+ if (continuousMatches.length === 0) return undefined;
322
+ const preferred = continuousMatches.find(m => (m.palette !== undefined) === twoColorMode);
323
+ return preferred ?? continuousMatches[0];
324
+ }
325
+ return Object.values(MEDIA).find(
326
+ m => m.type === 'die-cut' && m.widthMm === widthMm && m.heightMm === heightMm,
327
+ );
328
+ }
@@ -0,0 +1,11 @@
1
+ import type { RotateDirection } from '@thermal-label/contracts';
2
+
3
+ /**
4
+ * Direction the Brother QL print head rotates landscape input.
5
+ *
6
+ * `90` = clockwise. Verified once on hardware with a die-cut "F"
7
+ * landscape print (see plan §6 step 1). Identical across every QL
8
+ * model — this is a print-head/leading-edge mechanical fact, not a
9
+ * per-media setting.
10
+ */
11
+ export const ROTATE_DIRECTION: RotateDirection = 90;
@@ -0,0 +1,64 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion --
2
+ * `noUncheckedIndexedAccess` types Uint8Array reads as `number | undefined`
3
+ * but every index in this file is bounded by `i < input.length` or
4
+ * `runEnd < input.length` checks. The `!` assertions collapse the
5
+ * unreachable `undefined` branches so branch coverage doesn't count them.
6
+ */
7
+
8
+ /**
9
+ * TIFF-style PackBits row encoder used by Brother QL when compression
10
+ * mode (`M 02`, emitted by `buildCompression(true)`) is enabled for a job.
11
+ *
12
+ * Header byte `n` (interpreted as a signed int8):
13
+ * n in [0, 127]: literal run — the next `n + 1` bytes follow verbatim.
14
+ * n in [-127, -1]: repeat run — the next byte is repeated `1 - n` times.
15
+ * n = -128: no-op. Unused by this encoder.
16
+ *
17
+ * The encoder switches to repeat mode for runs of two or more identical
18
+ * bytes (a 2-byte repeat costs 2 wire bytes; the equivalent 2-byte literal
19
+ * would cost 3). Both run kinds are capped at 128 bytes to fit the header.
20
+ *
21
+ * For a typical Brother QL raster row (90 bytes, mostly zeros in margins
22
+ * and long runs of `0x00` / `0xff` in print area), this compresses to 5–15
23
+ * bytes — a 6–18× reduction. Worst-case for highly random input is one
24
+ * extra byte per 128 (the literal-mode header), i.e. < 1 % expansion.
25
+ */
26
+ export function packBits(input: Uint8Array): Uint8Array {
27
+ if (input.length === 0) return new Uint8Array(0);
28
+
29
+ const out: number[] = [];
30
+ let i = 0;
31
+
32
+ while (i < input.length) {
33
+ // How many identical bytes start at position i (cap at 128).
34
+ let runEnd = i + 1;
35
+ while (runEnd < input.length && runEnd - i < 128 && input[runEnd] === input[i]) {
36
+ runEnd++;
37
+ }
38
+ const runLen = runEnd - i;
39
+
40
+ if (runLen >= 2) {
41
+ // Repeat run: header is signed -(runLen - 1).
42
+ out.push((1 - runLen) & 0xff);
43
+ out.push(input[i]!);
44
+ i = runEnd;
45
+ continue;
46
+ }
47
+
48
+ // Otherwise emit a literal run. Stop at 128 bytes, or right before a
49
+ // 2+ repeat run starts (cheaper to encode that separately).
50
+ let litEnd = i + 1;
51
+ while (litEnd < input.length && litEnd - i < 128) {
52
+ if (litEnd + 1 < input.length && input[litEnd] === input[litEnd + 1]) {
53
+ break;
54
+ }
55
+ litEnd++;
56
+ }
57
+ const litLen = litEnd - i;
58
+ out.push(litLen - 1);
59
+ for (let x = i; x < litEnd; x++) out.push(input[x]!);
60
+ i = litEnd;
61
+ }
62
+
63
+ return new Uint8Array(out);
64
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { renderImage, renderMultiPlaneImage } from '@mbtech-nl/bitmap';
2
+ import type { LabelBitmap, RawImageData } from '@mbtech-nl/bitmap';
3
+ import type { PreviewResult } from '@thermal-label/contracts';
4
+ import type { BrotherQLMedia } from './types.js';
5
+
6
+ /**
7
+ * Offline preview without a live printer connection.
8
+ *
9
+ * Multi-ink aware: when `media.palette` is defined (DK-22251 today),
10
+ * the image is split per-plane via `renderMultiPlaneImage()` — the
11
+ * same code path `print()` takes for that media. Single-ink media
12
+ * returns one black plane via dithered `renderImage`.
13
+ */
14
+ export function createPreviewOffline(image: RawImageData, media: BrotherQLMedia): PreviewResult {
15
+ if (media.palette) {
16
+ const planes = renderMultiPlaneImage(image, {
17
+ palette: media.palette,
18
+ }) as Record<'black' | 'red', LabelBitmap>;
19
+ return {
20
+ planes: [
21
+ { name: 'black', bitmap: planes.black, displayColor: '#000000' },
22
+ { name: 'red', bitmap: planes.red, displayColor: '#ff0000' },
23
+ ],
24
+ media,
25
+ assumed: false,
26
+ };
27
+ }
28
+
29
+ const bitmap = renderImage(image, { dither: true });
30
+ return {
31
+ planes: [{ name: 'black', bitmap, displayColor: '#000000' }],
32
+ media,
33
+ assumed: false,
34
+ };
35
+ }
package/src/protocol.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getRow, createBitmap } from '@mbtech-nl/bitmap';
2
- import { type MediaDescriptor, type PageData, type JobOptions, type PageOptions } from './types.js';
2
+ import { packBits } from './pack-bits.js';
3
+ import type { BrotherQLMedia, PageData, JobOptions, PageOptions } from './types.js';
3
4
 
4
5
  export function buildInvalidate(): Uint8Array {
5
6
  return new Uint8Array(200);
@@ -22,7 +23,7 @@ export function buildStatusNotification(enabled: boolean): Uint8Array {
22
23
  }
23
24
 
24
25
  export function buildPrintInfo(
25
- media: MediaDescriptor,
26
+ media: BrotherQLMedia,
26
27
  rowCount: number,
27
28
  pageIndex: number,
28
29
  ): Uint8Array {
@@ -38,7 +39,7 @@ export function buildPrintInfo(
38
39
  buf[3] = validFlags;
39
40
  buf[4] = mediaType;
40
41
  buf[5] = media.widthMm;
41
- buf[6] = media.lengthMm;
42
+ buf[6] = media.heightMm ?? 0;
42
43
  // rowCount little-endian at bytes 7-8 (offsets 4-5 in param block)
43
44
  buf[7] = rowCount & 0xff;
44
45
  buf[8] = (rowCount >> 8) & 0xff;
@@ -161,12 +162,12 @@ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Arr
161
162
  const compress = opts.compress ?? false;
162
163
  const { bitmap, media } = page;
163
164
 
164
- // twoColorTape media (e.g. DK-22251) requires two-color mode even for black-only jobs.
165
+ // Multi-ink media (e.g. DK-22251) requires two-color mode even for black-only jobs.
165
166
  // Auto-create an empty red plane when the tape demands it but caller didn't supply one.
166
- const twoColor = page.redBitmap !== undefined || media.twoColorTape === true;
167
+ const multiInk = media.palette !== undefined;
168
+ const twoColor = page.redBitmap !== undefined || multiInk;
167
169
  const redBitmap =
168
- page.redBitmap ??
169
- (media.twoColorTape ? createBitmap(bitmap.widthPx, bitmap.heightPx) : undefined);
170
+ page.redBitmap ?? (multiInk ? createBitmap(bitmap.widthPx, bitmap.heightPx) : undefined);
170
171
 
171
172
  if (twoColor && redBitmap !== undefined) {
172
173
  if (bitmap.widthPx !== redBitmap.widthPx || bitmap.heightPx !== redBitmap.heightPx) {
@@ -192,17 +193,23 @@ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Arr
192
193
 
193
194
  // Rows interleaved per raster line (matches Python brother_ql behaviour).
194
195
  // Two-color: black row then red row for each line. Single-color: black only.
196
+ // 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.
195
200
  for (let r = 0; r < rowCount; r++) {
196
201
  const blackSrc = getRow(bitmap, r);
197
202
  const blackBytes = new Uint8Array(rowByteLen);
198
203
  placeBits(blackSrc, bitmap.widthPx, blackBytes, media.leftMarginPins);
199
- chunks.push(buildRasterRow(blackBytes, 'black', twoColor));
204
+ const blackPayload = compress ? packBits(blackBytes) : blackBytes;
205
+ chunks.push(buildRasterRow(blackPayload, 'black', twoColor));
200
206
 
201
207
  if (twoColor && redBitmap !== undefined) {
202
208
  const redSrc = getRow(redBitmap, r);
203
209
  const redBytes = new Uint8Array(rowByteLen);
204
210
  placeBits(redSrc, redBitmap.widthPx, redBytes, media.leftMarginPins);
205
- chunks.push(buildRasterRow(redBytes, 'red', twoColor));
211
+ const redPayload = compress ? packBits(redBytes) : redBytes;
212
+ chunks.push(buildRasterRow(redPayload, 'red', twoColor));
206
213
  }
207
214
  }
208
215
 
package/src/status.ts CHANGED
@@ -1,67 +1,82 @@
1
- import { type PrinterStatus, type MediaType } from './types.js';
1
+ import type { PrinterError } from '@thermal-label/contracts';
2
+ import type { BrotherQLStatus } from './types.js';
3
+ import { findMediaByDimensions } from './media.js';
2
4
 
3
- const ERROR_INFO_1: Record<number, string> = {
4
- 0: 'No media',
5
- 1: 'End of media',
6
- 2: 'Cutter jam',
7
- 3: 'Weak battery',
8
- 4: 'Printer in use',
9
- 6: 'High voltage adapter',
10
- 7: 'Fan motor error',
11
- };
5
+ export const STATUS_REQUEST = new Uint8Array([0x1b, 0x69, 0x53]);
6
+
7
+ /** Error-info 1 bit structured PrinterError code + human message. */
8
+ const ERROR_INFO_1: { bit: number; code: string; message: string }[] = [
9
+ { bit: 0, code: 'no_media', message: 'No media' },
10
+ { bit: 1, code: 'media_end', message: 'End of media' },
11
+ { bit: 2, code: 'cutter_jam', message: 'Cutter jam' },
12
+ { bit: 3, code: 'system_error', message: 'Weak battery' },
13
+ { bit: 4, code: 'not_ready', message: 'Printer in use' },
14
+ { bit: 6, code: 'system_error', message: 'High voltage adapter' },
15
+ { bit: 7, code: 'system_error', message: 'Fan motor error' },
16
+ ];
12
17
 
13
- const ERROR_INFO_2: Record<number, string> = {
14
- 0: 'Replace media',
15
- 1: 'Expansion buffer full',
16
- 2: 'Transmission error',
17
- 3: 'Communication buffer full',
18
- 4: 'Cover open',
19
- 5: 'Cancel key',
20
- 6: 'Media cannot be fed',
21
- 7: 'System error',
22
- };
18
+ /** Error-info 2 bit structured PrinterError code + human message. */
19
+ const ERROR_INFO_2: { bit: number; code: string; message: string }[] = [
20
+ { bit: 0, code: 'wrong_media', message: 'Replace media' },
21
+ { bit: 1, code: 'system_error', message: 'Expansion buffer full' },
22
+ { bit: 2, code: 'system_error', message: 'Transmission error' },
23
+ { bit: 3, code: 'system_error', message: 'Communication buffer full' },
24
+ { bit: 4, code: 'cover_open', message: 'Cover open' },
25
+ { bit: 5, code: 'not_ready', message: 'Cancel key pressed' },
26
+ { bit: 6, code: 'media_end', message: 'Media cannot be fed' },
27
+ { bit: 7, code: 'system_error', message: 'System error' },
28
+ ];
23
29
 
24
- export function parseStatus(bytes: Uint8Array): PrinterStatus {
25
- if (bytes.length < 32)
30
+ /**
31
+ * Parse a Brother QL 32-byte status response.
32
+ *
33
+ * Fields:
34
+ * byte 8 — error info 1 (bit mask, see ERROR_INFO_1)
35
+ * byte 9 — error info 2 (bit mask, see ERROR_INFO_2)
36
+ * byte 10 — media width (mm)
37
+ * byte 11 — media type (0x0A continuous, 0x0B die-cut)
38
+ * byte 17 — media length (mm), 0 for continuous
39
+ * byte 18 — status type (0x02 = error response)
40
+ *
41
+ * `detectedMedia` is resolved against the media registry via
42
+ * `findMediaByDimensions`. `editorLiteMode` is a driver-specific
43
+ * extension on `BrotherQLStatus` — the status-type byte doesn't
44
+ * actually report it, but keeping the field here means callers can
45
+ * set it from other signals (e.g. mass-storage PID detected during
46
+ * discovery) without changing the return type.
47
+ */
48
+ export function parseStatus(bytes: Uint8Array): BrotherQLStatus {
49
+ if (bytes.length < 32) {
26
50
  throw new Error(`Status response too short: ${bytes.length.toString()} bytes`);
51
+ }
27
52
 
28
- // noUncheckedIndexedAccess forces `?? 0` fallbacks that are unreachable after
29
- // the length check above. DataView avoids the issue: getUint8 returns number.
30
53
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31
-
32
- const errors: string[] = [];
33
54
  const errInfo1 = view.getUint8(8);
34
55
  const errInfo2 = view.getUint8(9);
35
-
36
- for (const [bitStr, msg] of Object.entries(ERROR_INFO_1)) {
37
- if (errInfo1 & (1 << Number(bitStr))) errors.push(msg);
38
- }
39
- for (const [bitStr, msg] of Object.entries(ERROR_INFO_2)) {
40
- if (errInfo2 & (1 << Number(bitStr))) errors.push(msg);
41
- }
42
-
43
56
  const mediaWidthMm = view.getUint8(10);
44
57
  const mediaTypeByte = view.getUint8(11);
45
58
  const mediaLengthMm = view.getUint8(17);
59
+ const statusType = view.getUint8(18);
46
60
 
47
- let mediaType: MediaType | null = null;
48
- if (mediaTypeByte === 0x0a) mediaType = 'continuous';
49
- else if (mediaTypeByte === 0x0b) mediaType = 'die-cut';
61
+ const errors: PrinterError[] = [];
62
+ for (const { bit, code, message } of ERROR_INFO_1) {
63
+ if (errInfo1 & (1 << bit)) errors.push({ code, message });
64
+ }
65
+ for (const { bit, code, message } of ERROR_INFO_2) {
66
+ if (errInfo2 & (1 << bit)) errors.push({ code, message });
67
+ }
50
68
 
51
- // Status type is at byte 18, not 14. Byte 14 is an undocumented media-type
52
- // code that carries non-zero values and is not the status type field.
53
- const statusType = view.getUint8(18);
54
- const ready = errors.length === 0 && statusType !== 0x02;
69
+ const mediaLoaded = mediaWidthMm > 0 && mediaTypeByte !== 0;
70
+ const detected = mediaLoaded
71
+ ? findMediaByDimensions(mediaWidthMm, mediaLengthMm, false)
72
+ : undefined;
55
73
 
56
74
  return {
57
- ready,
58
- mediaWidthMm,
59
- mediaLengthMm,
60
- mediaType,
75
+ ready: errors.length === 0 && statusType !== 0x02,
76
+ mediaLoaded,
77
+ ...(detected === undefined ? {} : { detectedMedia: detected }),
61
78
  errors,
62
79
  editorLiteMode: false,
63
80
  rawBytes: bytes,
64
81
  };
65
82
  }
66
-
67
- export const STATUS_REQUEST = new Uint8Array([0x1b, 0x69, 0x53]);