@thermal-label/brother-ql-core 0.0.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/colour.test.d.ts +2 -0
- package/dist/__tests__/colour.test.d.ts.map +1 -0
- package/dist/__tests__/colour.test.js +106 -0
- package/dist/__tests__/colour.test.js.map +1 -0
- package/dist/__tests__/devices.test.js +16 -2
- package/dist/__tests__/devices.test.js.map +1 -1
- package/dist/__tests__/media.test.js +17 -6
- package/dist/__tests__/media.test.js.map +1 -1
- package/dist/__tests__/preview.test.d.ts +2 -0
- package/dist/__tests__/preview.test.d.ts.map +1 -0
- package/dist/__tests__/preview.test.js +41 -0
- package/dist/__tests__/preview.test.js.map +1 -0
- package/dist/__tests__/status.test.js +28 -22
- package/dist/__tests__/status.test.js.map +1 -1
- package/dist/colour.d.ts +26 -0
- package/dist/colour.d.ts.map +1 -0
- package/dist/colour.js +84 -0
- package/dist/colour.js.map +1 -0
- package/dist/devices.d.ts +40 -21
- package/dist/devices.d.ts.map +1 -1
- package/dist/devices.js +38 -19
- package/dist/devices.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +31 -4
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +73 -23
- package/dist/media.js.map +1 -1
- package/dist/preview.d.ts +13 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +32 -0
- package/dist/preview.js.map +1 -0
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +4 -4
- package/dist/protocol.js.map +1 -1
- package/dist/status.d.ts +20 -2
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +59 -44
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +36 -30
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/colour.test.ts +126 -0
- package/src/__tests__/devices.test.ts +18 -2
- package/src/__tests__/media.test.ts +17 -6
- package/src/__tests__/preview.test.ts +52 -0
- package/src/__tests__/status.test.ts +31 -22
- package/src/colour.ts +101 -0
- package/src/devices.ts +41 -22
- package/src/index.ts +31 -9
- package/src/media.ts +86 -27
- package/src/preview.ts +34 -0
- package/src/protocol.ts +6 -6
- package/src/status.ts +63 -47
- package/src/types.ts +38 -33
package/dist/types.d.ts
CHANGED
|
@@ -1,39 +1,65 @@
|
|
|
1
1
|
import { type LabelBitmap } from '@mbtech-nl/bitmap';
|
|
2
|
+
import type { DeviceDescriptor, MediaDescriptor, PrinterStatus } from '@thermal-label/contracts';
|
|
2
3
|
export type MediaType = 'continuous' | 'die-cut';
|
|
3
4
|
export type HeadWidth = 720 | 1296;
|
|
4
5
|
export type ColorMode = 'single' | 'two-color';
|
|
5
6
|
export type NetworkSupport = 'none' | 'wifi' | 'wired' | 'wifi+wired';
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Brother QL device descriptor.
|
|
9
|
+
*
|
|
10
|
+
* Extends the contracts base with QL-specific fields: head geometry,
|
|
11
|
+
* protocol feature flags, and the optional mass-storage PID for Editor
|
|
12
|
+
* Lite mode.
|
|
13
|
+
*
|
|
14
|
+
* **Bluetooth on the QL-820NWB / 820NWBc**: not exposed over GATT.
|
|
15
|
+
* Classic Bluetooth (SPP) is paired at the OS level; the kernel/driver
|
|
16
|
+
* exposes an RFCOMM serial port, reachable via the `'serial'` transport
|
|
17
|
+
* in Node.js and the `'web-serial'` transport in Chrome/Edge. macOS has
|
|
18
|
+
* dropped classic Bluetooth SPP — no serial route there.
|
|
19
|
+
*/
|
|
20
|
+
export interface BrotherQLDevice extends DeviceDescriptor {
|
|
21
|
+
family: 'brother-ql';
|
|
8
22
|
vid: number;
|
|
9
23
|
pid: number;
|
|
10
24
|
headPins: HeadWidth;
|
|
11
25
|
bytesPerRow: number;
|
|
12
26
|
twoColor: boolean;
|
|
13
27
|
network: NetworkSupport;
|
|
14
|
-
bluetooth: boolean;
|
|
15
28
|
autocut: boolean;
|
|
16
29
|
compression: boolean;
|
|
17
30
|
editorLite: boolean;
|
|
31
|
+
/** Alternate PID seen when the printer is in Editor Lite mass-storage mode. */
|
|
18
32
|
massStoragePid?: number;
|
|
19
33
|
}
|
|
20
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Brother QL media descriptor.
|
|
36
|
+
*
|
|
37
|
+
* Extends `MediaDescriptor` with the dots-based geometry the raster
|
|
38
|
+
* encoder needs. `colorCapable: true` flips the driver into
|
|
39
|
+
* two-colour mode — only DK-22251 has this set in the registry.
|
|
40
|
+
*/
|
|
41
|
+
export interface BrotherQLMedia extends MediaDescriptor {
|
|
21
42
|
id: number;
|
|
22
|
-
name: string;
|
|
23
43
|
type: MediaType;
|
|
24
|
-
|
|
25
|
-
lengthMm: number;
|
|
44
|
+
colorCapable: boolean;
|
|
26
45
|
printAreaDots: number;
|
|
27
46
|
leftMarginPins: number;
|
|
28
47
|
rightMarginPins: number;
|
|
48
|
+
/** Die-cut masked area in dots (registration windows). */
|
|
29
49
|
dieCutMaskedAreaDots?: number;
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Brother QL status — contracts `PrinterStatus` plus the
|
|
53
|
+
* `editorLiteMode` flag (pre-paired QL-820NWB silently drops raster
|
|
54
|
+
* jobs when in Editor Lite mode; callers need to know).
|
|
55
|
+
*/
|
|
56
|
+
export interface BrotherQLStatus extends PrinterStatus {
|
|
57
|
+
editorLiteMode: boolean;
|
|
32
58
|
}
|
|
33
59
|
export interface PageData {
|
|
34
60
|
bitmap: LabelBitmap;
|
|
35
61
|
redBitmap?: LabelBitmap;
|
|
36
|
-
media:
|
|
62
|
+
media: BrotherQLMedia;
|
|
37
63
|
options?: PageOptions;
|
|
38
64
|
}
|
|
39
65
|
export interface PageOptions {
|
|
@@ -46,24 +72,4 @@ export interface PageOptions {
|
|
|
46
72
|
export interface JobOptions {
|
|
47
73
|
copies?: number;
|
|
48
74
|
}
|
|
49
|
-
export interface TextPrintOptions extends PageOptions {
|
|
50
|
-
invert?: boolean;
|
|
51
|
-
scaleX?: number;
|
|
52
|
-
scaleY?: number;
|
|
53
|
-
}
|
|
54
|
-
export interface ImagePrintOptions extends PageOptions {
|
|
55
|
-
threshold?: number;
|
|
56
|
-
dither?: boolean;
|
|
57
|
-
invert?: boolean;
|
|
58
|
-
rotate?: 0 | 90 | 180 | 270;
|
|
59
|
-
}
|
|
60
|
-
export interface PrinterStatus {
|
|
61
|
-
ready: boolean;
|
|
62
|
-
mediaWidthMm: number;
|
|
63
|
-
mediaLengthMm: number;
|
|
64
|
-
mediaType: MediaType | null;
|
|
65
|
-
errors: string[];
|
|
66
|
-
editorLiteMode: boolean;
|
|
67
|
-
rawBytes: Uint8Array;
|
|
68
|
-
}
|
|
69
75
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEjG,MAAM,MAAM,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AACjD,MAAM,MAAM,SAAS,GAAG,GAAG,GAAG,IAAI,CAAC;AACnC,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,WAAW,CAAC;AAC/C,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,YAAY,CAAC;AAEtE;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,eAAgB,SAAQ,gBAAgB;IACvD,MAAM,EAAE,YAAY,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,SAAS,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,cAAc,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;IACpB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,cAAe,SAAQ,eAAe;IACrD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,CAAC;IAChB,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;IACxB,0DAA0D;IAC1D,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,WAAW,CAAC;IACpB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
|
package/dist/types.js
CHANGED
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,MAAM,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,OAAO,EAAoB,MAAM,mBAAmB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thermal-label/brother-ql-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Protocol encoding, device registry, and media registry for Brother QL label printers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"brother",
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@mbtech-nl/bitmap": "^0.1.0"
|
|
56
|
+
"@mbtech-nl/bitmap": "^0.1.0",
|
|
57
|
+
"@thermal-label/contracts": "^0.1.2"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@mbtech-nl/tsconfig": "^1.0.0",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getPixel } from '@mbtech-nl/bitmap';
|
|
3
|
+
import { isRedish, splitTwoColor } from '../colour.js';
|
|
4
|
+
|
|
5
|
+
function rgbaOf(
|
|
6
|
+
width: number,
|
|
7
|
+
height: number,
|
|
8
|
+
[r, g, b, a]: [number, number, number, number],
|
|
9
|
+
): {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
} {
|
|
14
|
+
const data = new Uint8Array(width * height * 4);
|
|
15
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
16
|
+
data[i] = r;
|
|
17
|
+
data[i + 1] = g;
|
|
18
|
+
data[i + 2] = b;
|
|
19
|
+
data[i + 3] = a;
|
|
20
|
+
}
|
|
21
|
+
return { width, height, data };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function countInkPixels(bitmap: { widthPx: number; heightPx: number; data: Uint8Array }): number {
|
|
25
|
+
let n = 0;
|
|
26
|
+
for (let y = 0; y < bitmap.heightPx; y++) {
|
|
27
|
+
for (let x = 0; x < bitmap.widthPx; x++) {
|
|
28
|
+
if (getPixel(bitmap, x, y)) n++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('isRedish', () => {
|
|
35
|
+
it('treats a strong red as red', () => {
|
|
36
|
+
expect(isRedish(255, 0, 0, 255)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects red when green is too high (threshold g < 100)', () => {
|
|
40
|
+
expect(isRedish(255, 100, 0, 255)).toBe(false);
|
|
41
|
+
expect(isRedish(255, 99, 0, 255)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects red when blue is too high (threshold b < 100)', () => {
|
|
45
|
+
expect(isRedish(255, 0, 100, 255)).toBe(false);
|
|
46
|
+
expect(isRedish(255, 0, 99, 255)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects red when the red channel is too low (threshold r > 180)', () => {
|
|
50
|
+
expect(isRedish(180, 0, 0, 255)).toBe(false);
|
|
51
|
+
expect(isRedish(181, 0, 0, 255)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects transparent pixels (alpha < 128)', () => {
|
|
55
|
+
expect(isRedish(255, 0, 0, 127)).toBe(false);
|
|
56
|
+
expect(isRedish(255, 0, 0, 128)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects black (no red dominance)', () => {
|
|
60
|
+
expect(isRedish(0, 0, 0, 255)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects white', () => {
|
|
64
|
+
expect(isRedish(255, 255, 255, 255)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('splitTwoColor', () => {
|
|
69
|
+
it('routes a solid red image to the red plane, nothing to black', () => {
|
|
70
|
+
const { black, red } = splitTwoColor(rgbaOf(8, 8, [255, 0, 0, 255]));
|
|
71
|
+
expect(countInkPixels(red)).toBeGreaterThan(0);
|
|
72
|
+
expect(countInkPixels(black)).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('routes a solid black image to the black plane, nothing to red', () => {
|
|
76
|
+
const { black, red } = splitTwoColor(rgbaOf(8, 8, [0, 0, 0, 255]));
|
|
77
|
+
expect(countInkPixels(black)).toBeGreaterThan(0);
|
|
78
|
+
expect(countInkPixels(red)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('produces bitmaps matching the source dimensions', () => {
|
|
82
|
+
const { black, red } = splitTwoColor(rgbaOf(16, 12, [128, 128, 128, 255]));
|
|
83
|
+
expect(black.widthPx).toBe(16);
|
|
84
|
+
expect(black.heightPx).toBe(12);
|
|
85
|
+
expect(red.widthPx).toBe(16);
|
|
86
|
+
expect(red.heightPx).toBe(12);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('resolves overlapping bits in favour of black (red bit cleared)', () => {
|
|
90
|
+
// Construct a mixed image: one row red, one row black.
|
|
91
|
+
const data = new Uint8Array(8 * 2 * 4);
|
|
92
|
+
// Row 0: red
|
|
93
|
+
for (let i = 0; i < 8; i++) {
|
|
94
|
+
data[i * 4] = 255;
|
|
95
|
+
data[i * 4 + 3] = 255;
|
|
96
|
+
}
|
|
97
|
+
// Row 1: black
|
|
98
|
+
for (let i = 0; i < 8; i++) {
|
|
99
|
+
const offset = (8 + i) * 4;
|
|
100
|
+
data[offset] = 0;
|
|
101
|
+
data[offset + 1] = 0;
|
|
102
|
+
data[offset + 2] = 0;
|
|
103
|
+
data[offset + 3] = 255;
|
|
104
|
+
}
|
|
105
|
+
const image = { width: 8, height: 2, data };
|
|
106
|
+
const { black, red } = splitTwoColor(image);
|
|
107
|
+
// Every non-transparent pixel should land on exactly one plane —
|
|
108
|
+
// resolveOverlap guarantees no bit is set in both.
|
|
109
|
+
for (let y = 0; y < 2; y++) {
|
|
110
|
+
for (let x = 0; x < 8; x++) {
|
|
111
|
+
const inBlack = getPixel(black, x, y);
|
|
112
|
+
const inRed = getPixel(red, x, y);
|
|
113
|
+
expect(inBlack && inRed).toBe(false);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('accepts custom threshold + dither options', () => {
|
|
119
|
+
const { black, red } = splitTwoColor(rgbaOf(4, 4, [0, 0, 0, 255]), {
|
|
120
|
+
threshold: 64,
|
|
121
|
+
dither: false,
|
|
122
|
+
});
|
|
123
|
+
expect(black.widthPx).toBe(4);
|
|
124
|
+
expect(red.widthPx).toBe(4);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -65,9 +65,25 @@ describe('Device registry invariants', () => {
|
|
|
65
65
|
}
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
it('every device
|
|
68
|
+
it('every device belongs to the brother-ql family', () => {
|
|
69
69
|
for (const dev of Object.values(DEVICES)) {
|
|
70
|
-
expect(dev.
|
|
70
|
+
expect(dev.family).toBe('brother-ql');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('QL-820NWB(c) advertise serial/web-serial for OS-paired Bluetooth', () => {
|
|
75
|
+
for (const key of ['QL_820NWB', 'QL_820NWBc'] as const) {
|
|
76
|
+
const dev = DEVICES[key];
|
|
77
|
+
expect(dev.transports).toContain('serial');
|
|
78
|
+
expect(dev.transports).toContain('web-serial');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('no device descriptor declares web-bluetooth', () => {
|
|
83
|
+
// Bluetooth on the 820 series is classic SPP, not GATT — the
|
|
84
|
+
// serial transports cover it. See packages/core/src/types.ts.
|
|
85
|
+
for (const dev of Object.values(DEVICES)) {
|
|
86
|
+
expect(dev.transports).not.toContain('web-bluetooth');
|
|
71
87
|
}
|
|
72
88
|
});
|
|
73
89
|
});
|
|
@@ -7,14 +7,14 @@ describe('findMedia', () => {
|
|
|
7
7
|
expect(m).toBeDefined();
|
|
8
8
|
expect(m!.widthMm).toBe(62);
|
|
9
9
|
expect(m!.type).toBe('continuous');
|
|
10
|
-
expect(m!.
|
|
10
|
+
expect(m!.heightMm).toBeUndefined();
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('returns correct descriptor for 62x29mm die-cut (ID 274)', () => {
|
|
14
14
|
const m = findMedia(274);
|
|
15
15
|
expect(m).toBeDefined();
|
|
16
16
|
expect(m!.type).toBe('die-cut');
|
|
17
|
-
expect(m!.
|
|
17
|
+
expect(m!.heightMm).toBe(29);
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('returns undefined for unknown ID', () => {
|
|
@@ -46,18 +46,29 @@ describe('findMediaByWidth', () => {
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
describe('Media registry invariants', () => {
|
|
49
|
-
it('die-cut media has
|
|
49
|
+
it('die-cut media has a heightMm', () => {
|
|
50
50
|
for (const m of Object.values(MEDIA)) {
|
|
51
51
|
if (m.type === 'die-cut') {
|
|
52
|
-
expect(m.
|
|
52
|
+
expect(m.heightMm).toBeDefined();
|
|
53
|
+
expect(m.heightMm!).toBeGreaterThan(0);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
it('continuous media
|
|
58
|
+
it('continuous media omits heightMm', () => {
|
|
58
59
|
for (const m of Object.values(MEDIA)) {
|
|
59
60
|
if (m.type === 'continuous') {
|
|
60
|
-
expect(m.
|
|
61
|
+
expect(m.heightMm).toBeUndefined();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('only DK-22251 is colorCapable', () => {
|
|
67
|
+
for (const m of Object.values(MEDIA)) {
|
|
68
|
+
if (m.id === 251) {
|
|
69
|
+
expect(m.colorCapable).toBe(true);
|
|
70
|
+
} else {
|
|
71
|
+
expect(m.colorCapable).toBe(false);
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MEDIA } from '../media.js';
|
|
3
|
+
import { createPreviewOffline } from '../preview.js';
|
|
4
|
+
|
|
5
|
+
function solidRgba(
|
|
6
|
+
width: number,
|
|
7
|
+
height: number,
|
|
8
|
+
rgba: [number, number, number, number],
|
|
9
|
+
): {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
} {
|
|
14
|
+
const data = new Uint8Array(width * height * 4);
|
|
15
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
16
|
+
data[i] = rgba[0];
|
|
17
|
+
data[i + 1] = rgba[1];
|
|
18
|
+
data[i + 2] = rgba[2];
|
|
19
|
+
data[i + 3] = rgba[3];
|
|
20
|
+
}
|
|
21
|
+
return { width, height, data };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('createPreviewOffline', () => {
|
|
25
|
+
it('returns a single black plane for single-colour media', () => {
|
|
26
|
+
const image = solidRgba(8, 8, [0, 0, 0, 255]);
|
|
27
|
+
const preview = createPreviewOffline(image, MEDIA[259]!);
|
|
28
|
+
expect(preview.planes).toHaveLength(1);
|
|
29
|
+
expect(preview.planes[0]!.name).toBe('black');
|
|
30
|
+
expect(preview.planes[0]!.displayColor).toBe('#000000');
|
|
31
|
+
expect(preview.media).toBe(MEDIA[259]);
|
|
32
|
+
expect(preview.assumed).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns black + red planes for colorCapable media (DK-22251)', () => {
|
|
36
|
+
const image = solidRgba(8, 8, [255, 0, 0, 255]);
|
|
37
|
+
const preview = createPreviewOffline(image, MEDIA[251]!);
|
|
38
|
+
expect(preview.planes.map(p => p.name)).toEqual(['black', 'red']);
|
|
39
|
+
expect(preview.planes[1]!.displayColor).toBe('#ff0000');
|
|
40
|
+
expect(preview.media).toBe(MEDIA[251]);
|
|
41
|
+
expect(preview.assumed).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('planes share the source image dimensions', () => {
|
|
45
|
+
const image = solidRgba(24, 12, [128, 128, 128, 255]);
|
|
46
|
+
const preview = createPreviewOffline(image, MEDIA[251]!);
|
|
47
|
+
for (const plane of preview.planes) {
|
|
48
|
+
expect(plane.bitmap.widthPx).toBe(24);
|
|
49
|
+
expect(plane.bitmap.heightPx).toBe(12);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -33,44 +33,53 @@ describe('parseStatus', () => {
|
|
|
33
33
|
it('returns ready=true with no errors', () => {
|
|
34
34
|
const status = parseStatus(makeStatusBytes());
|
|
35
35
|
expect(status.ready).toBe(true);
|
|
36
|
-
expect(status.errors).
|
|
36
|
+
expect(status.errors).toEqual([]);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
it('
|
|
39
|
+
it('resolves 62mm continuous to detectedMedia', () => {
|
|
40
40
|
const status = parseStatus(makeStatusBytes({ mediaWidthMm: 62, mediaTypeByte: 0x0a }));
|
|
41
|
-
expect(status.
|
|
42
|
-
expect(status.
|
|
41
|
+
expect(status.mediaLoaded).toBe(true);
|
|
42
|
+
expect(status.detectedMedia?.id).toBe(259);
|
|
43
|
+
expect(status.detectedMedia?.widthMm).toBe(62);
|
|
44
|
+
expect(status.detectedMedia?.type).toBe('continuous');
|
|
43
45
|
});
|
|
44
46
|
|
|
45
|
-
it('
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(
|
|
47
|
+
it('resolves 62x29mm die-cut to detectedMedia', () => {
|
|
48
|
+
const status = parseStatus(
|
|
49
|
+
makeStatusBytes({ mediaWidthMm: 62, mediaTypeByte: 0x0b, mediaLengthMm: 29 }),
|
|
50
|
+
);
|
|
51
|
+
expect(status.detectedMedia?.id).toBe(274);
|
|
52
|
+
expect(status.detectedMedia?.type).toBe('die-cut');
|
|
53
|
+
expect(status.detectedMedia?.heightMm).toBe(29);
|
|
50
54
|
});
|
|
51
55
|
|
|
52
|
-
it('
|
|
53
|
-
const status = parseStatus(makeStatusBytes({ mediaTypeByte:
|
|
54
|
-
expect(status.
|
|
56
|
+
it('leaves detectedMedia undefined for unknown media', () => {
|
|
57
|
+
const status = parseStatus(makeStatusBytes({ mediaWidthMm: 100, mediaTypeByte: 0x0a }));
|
|
58
|
+
expect(status.detectedMedia).toBeUndefined();
|
|
55
59
|
});
|
|
56
60
|
|
|
57
|
-
it('
|
|
58
|
-
const status = parseStatus(makeStatusBytes({ mediaTypeByte:
|
|
59
|
-
expect(status.
|
|
61
|
+
it('leaves mediaLoaded=false when width is 0', () => {
|
|
62
|
+
const status = parseStatus(makeStatusBytes({ mediaWidthMm: 0, mediaTypeByte: 0 }));
|
|
63
|
+
expect(status.mediaLoaded).toBe(false);
|
|
60
64
|
});
|
|
61
65
|
|
|
62
|
-
it('
|
|
63
|
-
const status = parseStatus(makeStatusBytes({ errInfo1: 0b00000001 }));
|
|
64
|
-
expect(status.errors).toContain('
|
|
66
|
+
it('surfaces no_media error code for err1 bit 0', () => {
|
|
67
|
+
const status = parseStatus(makeStatusBytes({ errInfo1: 0b00000001 }));
|
|
68
|
+
expect(status.errors.map(e => e.code)).toContain('no_media');
|
|
65
69
|
expect(status.ready).toBe(false);
|
|
66
70
|
});
|
|
67
71
|
|
|
68
|
-
it('
|
|
69
|
-
const status = parseStatus(makeStatusBytes({ errInfo2: 0b00010000 }));
|
|
70
|
-
expect(status.errors).toContain('
|
|
72
|
+
it('surfaces cover_open error code for err2 bit 4', () => {
|
|
73
|
+
const status = parseStatus(makeStatusBytes({ errInfo2: 0b00010000 }));
|
|
74
|
+
expect(status.errors.map(e => e.code)).toContain('cover_open');
|
|
71
75
|
expect(status.ready).toBe(false);
|
|
72
76
|
});
|
|
73
77
|
|
|
78
|
+
it('surfaces cutter_jam error code for err1 bit 2', () => {
|
|
79
|
+
const status = parseStatus(makeStatusBytes({ errInfo1: 0b00000100 }));
|
|
80
|
+
expect(status.errors.map(e => e.code)).toContain('cutter_jam');
|
|
81
|
+
});
|
|
82
|
+
|
|
74
83
|
it('ready=false when statusType is error (0x02)', () => {
|
|
75
84
|
const status = parseStatus(makeStatusBytes({ statusType: 0x02 }));
|
|
76
85
|
expect(status.ready).toBe(false);
|
|
@@ -82,7 +91,7 @@ describe('parseStatus', () => {
|
|
|
82
91
|
expect(status.rawBytes).toEqual(bytes);
|
|
83
92
|
});
|
|
84
93
|
|
|
85
|
-
it('editorLiteMode is
|
|
94
|
+
it('editorLiteMode is on the BrotherQLStatus extension', () => {
|
|
86
95
|
const status = parseStatus(makeStatusBytes());
|
|
87
96
|
expect(status.editorLiteMode).toBe(false);
|
|
88
97
|
});
|
package/src/colour.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion --
|
|
2
|
+
* Uint8Array indexed reads are typed `number | undefined` under
|
|
3
|
+
* `noUncheckedIndexedAccess`, but every index in this file is bounded
|
|
4
|
+
* by `src.length` (a multiple of 4) or `Math.min(a.length, b.length)`.
|
|
5
|
+
* The `!` assertions collapse the unreachable `undefined` branches so
|
|
6
|
+
* branch coverage doesn't count them. See also `non-nullable-type-
|
|
7
|
+
* assertion-style` which rules out the alternative `as number` form.
|
|
8
|
+
*/
|
|
9
|
+
import { renderImage, type LabelBitmap, type RawImageData } from '@mbtech-nl/bitmap';
|
|
10
|
+
|
|
11
|
+
export interface TwoColorResult {
|
|
12
|
+
black: LabelBitmap;
|
|
13
|
+
red: LabelBitmap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TwoColorOptions {
|
|
17
|
+
threshold?: number;
|
|
18
|
+
dither?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A pixel is "red-ish" when the red channel clearly dominates and
|
|
23
|
+
* alpha is opaque enough to matter. The thresholds come from testing
|
|
24
|
+
* against DK-22251 output — wider tolerances let non-red warm tones
|
|
25
|
+
* bleed into the red plane.
|
|
26
|
+
*/
|
|
27
|
+
export function isRedish(r: number, g: number, b: number, a: number): boolean {
|
|
28
|
+
if (a < 128) return false;
|
|
29
|
+
return r > 180 && g < 100 && b < 100;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractRedPixels(image: RawImageData): RawImageData {
|
|
33
|
+
const src = image.data;
|
|
34
|
+
const dst = new Uint8Array(src.length);
|
|
35
|
+
for (let i = 0; i < src.length; i += 4) {
|
|
36
|
+
const r = src[i]!;
|
|
37
|
+
const g = src[i + 1]!;
|
|
38
|
+
const b = src[i + 2]!;
|
|
39
|
+
const a = src[i + 3]!;
|
|
40
|
+
if (isRedish(r, g, b, a)) {
|
|
41
|
+
dst[i] = r;
|
|
42
|
+
dst[i + 1] = g;
|
|
43
|
+
dst[i + 2] = b;
|
|
44
|
+
dst[i + 3] = a;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { data: dst, width: image.width, height: image.height };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractNonRedPixels(image: RawImageData): RawImageData {
|
|
51
|
+
const src = image.data;
|
|
52
|
+
const dst = new Uint8Array(src.length);
|
|
53
|
+
for (let i = 0; i < src.length; i += 4) {
|
|
54
|
+
const r = src[i]!;
|
|
55
|
+
const g = src[i + 1]!;
|
|
56
|
+
const b = src[i + 2]!;
|
|
57
|
+
const a = src[i + 3]!;
|
|
58
|
+
if (!isRedish(r, g, b, a)) {
|
|
59
|
+
dst[i] = r;
|
|
60
|
+
dst[i + 1] = g;
|
|
61
|
+
dst[i + 2] = b;
|
|
62
|
+
dst[i + 3] = a;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { data: dst, width: image.width, height: image.height };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where both planes have a set bit at the same position, black wins.
|
|
70
|
+
* Done by masking the red bits with the inverse of the black bits.
|
|
71
|
+
*/
|
|
72
|
+
function resolveOverlap(black: LabelBitmap, red: LabelBitmap): void {
|
|
73
|
+
const blackData = black.data;
|
|
74
|
+
const redData = red.data;
|
|
75
|
+
const len = Math.min(blackData.length, redData.length);
|
|
76
|
+
for (let i = 0; i < len; i++) {
|
|
77
|
+
redData[i] = redData[i]! & ~blackData[i]!;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Split an RGBA image into black and red 1bpp planes for Brother QL
|
|
83
|
+
* two-colour media (DK-22251).
|
|
84
|
+
*
|
|
85
|
+
* The rendering path matches `renderImage(image, { dither: true })`
|
|
86
|
+
* used by `print()` for single-colour media, so overall print density
|
|
87
|
+
* stays consistent regardless of media.
|
|
88
|
+
*/
|
|
89
|
+
export function splitTwoColor(image: RawImageData, options?: TwoColorOptions): TwoColorResult {
|
|
90
|
+
const { threshold = 128, dither = true } = options ?? {};
|
|
91
|
+
|
|
92
|
+
const blackImage = extractNonRedPixels(image);
|
|
93
|
+
const redImage = extractRedPixels(image);
|
|
94
|
+
|
|
95
|
+
const black = renderImage(blackImage, { threshold, dither });
|
|
96
|
+
const red = renderImage(redImage, { threshold, dither });
|
|
97
|
+
|
|
98
|
+
resolveOverlap(black, red);
|
|
99
|
+
|
|
100
|
+
return { black, red };
|
|
101
|
+
}
|