@thermal-label/brother-ql-core 0.0.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/dist/__tests__/devices.test.d.ts +2 -0
  4. package/dist/__tests__/devices.test.d.ts.map +1 -0
  5. package/dist/__tests__/devices.test.js +63 -0
  6. package/dist/__tests__/devices.test.js.map +1 -0
  7. package/dist/__tests__/media.test.d.ts +2 -0
  8. package/dist/__tests__/media.test.d.ts.map +1 -0
  9. package/dist/__tests__/media.test.js +62 -0
  10. package/dist/__tests__/media.test.js.map +1 -0
  11. package/dist/__tests__/protocol.test.d.ts +2 -0
  12. package/dist/__tests__/protocol.test.d.ts.map +1 -0
  13. package/dist/__tests__/protocol.test.js +202 -0
  14. package/dist/__tests__/protocol.test.js.map +1 -0
  15. package/dist/__tests__/status.test.d.ts +2 -0
  16. package/dist/__tests__/status.test.d.ts.map +1 -0
  17. package/dist/__tests__/status.test.js +74 -0
  18. package/dist/__tests__/status.test.js.map +1 -0
  19. package/dist/devices.d.ts +255 -0
  20. package/dist/devices.d.ts.map +1 -0
  21. package/dist/devices.js +260 -0
  22. package/dist/devices.js.map +1 -0
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +6 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/media.d.ts +5 -0
  28. package/dist/media.d.ts.map +1 -0
  29. package/dist/media.js +247 -0
  30. package/dist/media.js.map +1 -0
  31. package/dist/protocol.d.ts +17 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +173 -0
  34. package/dist/protocol.js.map +1 -0
  35. package/dist/status.d.ts +4 -0
  36. package/dist/status.d.ts.map +1 -0
  37. package/dist/status.js +61 -0
  38. package/dist/status.js.map +1 -0
  39. package/dist/types.d.ts +69 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -0
  43. package/package.json +71 -0
  44. package/src/__tests__/devices.test.ts +73 -0
  45. package/src/__tests__/media.test.ts +70 -0
  46. package/src/__tests__/protocol.test.ts +241 -0
  47. package/src/__tests__/status.test.ts +95 -0
  48. package/src/devices.ts +263 -0
  49. package/src/index.ts +22 -0
  50. package/src/media.ts +250 -0
  51. package/src/protocol.ts +213 -0
  52. package/src/status.ts +67 -0
  53. package/src/types.ts +77 -0
@@ -0,0 +1,213 @@
1
+ import { getRow, createBitmap } from '@mbtech-nl/bitmap';
2
+ import { type MediaDescriptor, type PageData, type JobOptions, type PageOptions } from './types.js';
3
+
4
+ export function buildInvalidate(): Uint8Array {
5
+ return new Uint8Array(200);
6
+ }
7
+
8
+ export function buildStatusRequest(): Uint8Array {
9
+ return new Uint8Array([0x1b, 0x69, 0x53]);
10
+ }
11
+
12
+ export function buildInitialize(): Uint8Array {
13
+ return new Uint8Array([0x1b, 0x40]);
14
+ }
15
+
16
+ export function buildRasterMode(): Uint8Array {
17
+ return new Uint8Array([0x1b, 0x69, 0x61, 0x01]);
18
+ }
19
+
20
+ export function buildStatusNotification(enabled: boolean): Uint8Array {
21
+ return new Uint8Array([0x1b, 0x69, 0x21, enabled ? 0x01 : 0x00]);
22
+ }
23
+
24
+ export function buildPrintInfo(
25
+ media: MediaDescriptor,
26
+ rowCount: number,
27
+ pageIndex: number,
28
+ ): Uint8Array {
29
+ const mediaType = media.type === 'continuous' ? 0x0a : 0x0b;
30
+ // 0xCE is what Python brother_ql uses for QL-820NWB (two-color capable model) regardless of
31
+ // whether the job uses two colors. Single-color models (QL-700 etc.) use 0x8E instead.
32
+ // We always use 0xCE here; callers can override per-device if needed.
33
+ const validFlags = 0xce;
34
+ const buf = new Uint8Array(13);
35
+ buf[0] = 0x1b;
36
+ buf[1] = 0x69;
37
+ buf[2] = 0x7a;
38
+ buf[3] = validFlags;
39
+ buf[4] = mediaType;
40
+ buf[5] = media.widthMm;
41
+ buf[6] = media.lengthMm;
42
+ // rowCount little-endian at bytes 7-8 (offsets 4-5 in param block)
43
+ buf[7] = rowCount & 0xff;
44
+ buf[8] = (rowCount >> 8) & 0xff;
45
+ buf[9] = pageIndex;
46
+ buf[10] = 0x00;
47
+ buf[11] = 0x00;
48
+ buf[12] = 0x00;
49
+ return buf;
50
+ }
51
+
52
+ export function buildVariousMode(autoCut: boolean): Uint8Array {
53
+ return new Uint8Array([0x1b, 0x69, 0x4d, autoCut ? 0x40 : 0x00]);
54
+ }
55
+
56
+ export function buildExpandedMode(
57
+ cutAtEnd: boolean,
58
+ highRes: boolean,
59
+ twoColor = false,
60
+ ): Uint8Array {
61
+ let flags = 0x00;
62
+ if (twoColor) flags |= 0x01;
63
+ if (cutAtEnd) flags |= 0x08;
64
+ if (highRes) flags |= 0x10;
65
+ return new Uint8Array([0x1b, 0x69, 0x4b, flags]);
66
+ }
67
+
68
+ export function buildCutEach(n: number): Uint8Array {
69
+ return new Uint8Array([0x1b, 0x69, 0x41, n & 0xff]);
70
+ }
71
+
72
+ export function buildMargin(dots: number): Uint8Array {
73
+ return new Uint8Array([0x1b, 0x69, 0x64, dots & 0xff, (dots >> 8) & 0xff]);
74
+ }
75
+
76
+ export function buildCompression(enabled: boolean): Uint8Array {
77
+ return new Uint8Array([0x4d, enabled ? 0x02 : 0x00]);
78
+ }
79
+
80
+ // Single-color: [0x67][0x00][len][data]
81
+ // Two-color black: [0x77][0x01][len][data] — interleaved per-row with red
82
+ // Two-color red: [0x77][0x02][len][data]
83
+ export function buildRasterRow(
84
+ rowBytes: Uint8Array,
85
+ color: 'black' | 'red',
86
+ twoColor = false,
87
+ ): Uint8Array {
88
+ const buf = new Uint8Array(3 + rowBytes.length);
89
+ if (twoColor) {
90
+ buf[0] = 0x77;
91
+ buf[1] = color === 'black' ? 0x01 : 0x02;
92
+ } else {
93
+ buf[0] = 0x67;
94
+ buf[1] = 0x00;
95
+ }
96
+ buf[2] = rowBytes.length;
97
+ buf.set(rowBytes, 3);
98
+ return buf;
99
+ }
100
+
101
+ export function buildZeroRow(): Uint8Array {
102
+ return new Uint8Array([0x5a]);
103
+ }
104
+
105
+ export function buildPrintCommand(isLastPage: boolean): Uint8Array {
106
+ return new Uint8Array([isLastPage ? 0x1a : 0x0c]);
107
+ }
108
+
109
+ // Copy srcWidthPx bits from src (MSB-first packed) into dst at bit offset dstOffsetBits.
110
+ function placeBits(
111
+ src: Uint8Array,
112
+ srcWidthPx: number,
113
+ dst: Uint8Array,
114
+ dstOffsetBits: number,
115
+ ): void {
116
+ for (let px = 0; px < srcWidthPx; px++) {
117
+ const srcBit = ((src[px >> 3] ?? 0) >> (7 - (px & 7))) & 1;
118
+ if (srcBit) {
119
+ const dstPx = dstOffsetBits + px;
120
+ const byteIdx = dstPx >> 3;
121
+ if (byteIdx < dst.length) dst[byteIdx] = (dst[byteIdx] ?? 0) | (1 << (7 - (dstPx & 7)));
122
+ }
123
+ }
124
+ }
125
+
126
+ function concat(...arrays: Uint8Array[]): Uint8Array {
127
+ const total = arrays.reduce((s, a) => s + a.length, 0);
128
+ const out = new Uint8Array(total);
129
+ let offset = 0;
130
+ for (const a of arrays) {
131
+ out.set(a, offset);
132
+ offset += a.length;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ export function encodeJob(pages: PageData[], options: JobOptions = {}): Uint8Array {
138
+ const copies = options.copies ?? 1;
139
+ const chunks: Uint8Array[] = [];
140
+
141
+ // Python brother_ql sequence: raster-mode first, then 200-byte invalidate, then init, then
142
+ // raster-mode again (matches observed working sequence for QL-820NWB).
143
+ chunks.push(buildRasterMode());
144
+ chunks.push(buildInvalidate());
145
+ chunks.push(buildInitialize());
146
+
147
+ const allPageInstances: PageData[] = [];
148
+ for (let c = 0; c < copies; c++) {
149
+ for (const page of pages) {
150
+ allPageInstances.push(page);
151
+ }
152
+ }
153
+
154
+ for (const [i, page] of allPageInstances.entries()) {
155
+ const isLastPage = i === allPageInstances.length - 1;
156
+ const opts: PageOptions = page.options ?? {};
157
+ const autoCut = opts.autoCut ?? true;
158
+ const cutAtEnd = opts.cutAtEnd ?? true;
159
+ const highRes = opts.highResolution ?? false;
160
+ const marginDots = opts.marginDots ?? 35;
161
+ const compress = opts.compress ?? false;
162
+ const { bitmap, media } = page;
163
+
164
+ // twoColorTape media (e.g. DK-22251) requires two-color mode even for black-only jobs.
165
+ // 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 redBitmap =
168
+ page.redBitmap ??
169
+ (media.twoColorTape ? createBitmap(bitmap.widthPx, bitmap.heightPx) : undefined);
170
+
171
+ if (twoColor && redBitmap !== undefined) {
172
+ if (bitmap.widthPx !== redBitmap.widthPx || bitmap.heightPx !== redBitmap.heightPx) {
173
+ throw new Error('Two-color bitmaps must have identical dimensions');
174
+ }
175
+ }
176
+
177
+ const rowCount = bitmap.heightPx;
178
+
179
+ chunks.push(buildRasterMode());
180
+ chunks.push(buildStatusRequest());
181
+ chunks.push(buildPrintInfo(media, rowCount, i));
182
+ chunks.push(buildVariousMode(autoCut));
183
+ chunks.push(buildCutEach(1));
184
+ chunks.push(buildExpandedMode(cutAtEnd, highRes, twoColor));
185
+ chunks.push(buildMargin(marginDots));
186
+ if (compress) chunks.push(buildCompression(true));
187
+
188
+ // Each raster row must cover the full print head width (derived from media geometry).
189
+ // leftMarginPins + printAreaDots + rightMarginPins = head pin count (720 or 1296).
190
+ const totalPins = media.leftMarginPins + media.printAreaDots + media.rightMarginPins;
191
+ const rowByteLen = Math.ceil(totalPins / 8);
192
+
193
+ // Rows interleaved per raster line (matches Python brother_ql behaviour).
194
+ // Two-color: black row then red row for each line. Single-color: black only.
195
+ for (let r = 0; r < rowCount; r++) {
196
+ const blackSrc = getRow(bitmap, r);
197
+ const blackBytes = new Uint8Array(rowByteLen);
198
+ placeBits(blackSrc, bitmap.widthPx, blackBytes, media.leftMarginPins);
199
+ chunks.push(buildRasterRow(blackBytes, 'black', twoColor));
200
+
201
+ if (twoColor && redBitmap !== undefined) {
202
+ const redSrc = getRow(redBitmap, r);
203
+ const redBytes = new Uint8Array(rowByteLen);
204
+ placeBits(redSrc, redBitmap.widthPx, redBytes, media.leftMarginPins);
205
+ chunks.push(buildRasterRow(redBytes, 'red', twoColor));
206
+ }
207
+ }
208
+
209
+ chunks.push(buildPrintCommand(isLastPage));
210
+ }
211
+
212
+ return concat(...chunks);
213
+ }
package/src/status.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { type PrinterStatus, type MediaType } from './types.js';
2
+
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
+ };
12
+
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
+ };
23
+
24
+ export function parseStatus(bytes: Uint8Array): PrinterStatus {
25
+ if (bytes.length < 32)
26
+ throw new Error(`Status response too short: ${bytes.length.toString()} bytes`);
27
+
28
+ // noUncheckedIndexedAccess forces `?? 0` fallbacks that are unreachable after
29
+ // the length check above. DataView avoids the issue: getUint8 returns number.
30
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31
+
32
+ const errors: string[] = [];
33
+ const errInfo1 = view.getUint8(8);
34
+ 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
+ const mediaWidthMm = view.getUint8(10);
44
+ const mediaTypeByte = view.getUint8(11);
45
+ const mediaLengthMm = view.getUint8(17);
46
+
47
+ let mediaType: MediaType | null = null;
48
+ if (mediaTypeByte === 0x0a) mediaType = 'continuous';
49
+ else if (mediaTypeByte === 0x0b) mediaType = 'die-cut';
50
+
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;
55
+
56
+ return {
57
+ ready,
58
+ mediaWidthMm,
59
+ mediaLengthMm,
60
+ mediaType,
61
+ errors,
62
+ editorLiteMode: false,
63
+ rawBytes: bytes,
64
+ };
65
+ }
66
+
67
+ export const STATUS_REQUEST = new Uint8Array([0x1b, 0x69, 0x53]);
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { type LabelBitmap } from '@mbtech-nl/bitmap';
2
+
3
+ export type MediaType = 'continuous' | 'die-cut';
4
+ export type HeadWidth = 720 | 1296;
5
+ export type ColorMode = 'single' | 'two-color';
6
+ export type NetworkSupport = 'none' | 'wifi' | 'wired' | 'wifi+wired';
7
+
8
+ export interface DeviceDescriptor {
9
+ name: string;
10
+ vid: number;
11
+ pid: number;
12
+ headPins: HeadWidth;
13
+ bytesPerRow: number;
14
+ twoColor: boolean;
15
+ network: NetworkSupport;
16
+ bluetooth: boolean;
17
+ autocut: boolean;
18
+ compression: boolean;
19
+ editorLite: boolean;
20
+ massStoragePid?: number;
21
+ }
22
+
23
+ export interface MediaDescriptor {
24
+ id: number;
25
+ name: string;
26
+ type: MediaType;
27
+ widthMm: number;
28
+ lengthMm: number;
29
+ printAreaDots: number;
30
+ leftMarginPins: number;
31
+ rightMarginPins: number;
32
+ dieCutMaskedAreaDots?: number;
33
+ /** True for DK-22251 and similar two-color tapes — printer rejects single-color jobs */
34
+ twoColorTape?: boolean;
35
+ }
36
+
37
+ export interface PageData {
38
+ bitmap: LabelBitmap;
39
+ redBitmap?: LabelBitmap;
40
+ media: MediaDescriptor;
41
+ options?: PageOptions;
42
+ }
43
+
44
+ export interface PageOptions {
45
+ autoCut?: boolean;
46
+ cutAtEnd?: boolean;
47
+ highResolution?: boolean;
48
+ marginDots?: number;
49
+ compress?: boolean;
50
+ }
51
+
52
+ export interface JobOptions {
53
+ copies?: number;
54
+ }
55
+
56
+ export interface TextPrintOptions extends PageOptions {
57
+ invert?: boolean;
58
+ scaleX?: number;
59
+ scaleY?: number;
60
+ }
61
+
62
+ export interface ImagePrintOptions extends PageOptions {
63
+ threshold?: number;
64
+ dither?: boolean;
65
+ invert?: boolean;
66
+ rotate?: 0 | 90 | 180 | 270;
67
+ }
68
+
69
+ export interface PrinterStatus {
70
+ ready: boolean;
71
+ mediaWidthMm: number;
72
+ mediaLengthMm: number;
73
+ mediaType: MediaType | null;
74
+ errors: string[];
75
+ editorLiteMode: boolean;
76
+ rawBytes: Uint8Array;
77
+ }