jc-printer-sdk-ts 0.1.0 → 0.1.2

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 (41) hide show
  1. package/README.md +65 -0
  2. package/dist/browserImages.d.ts +43 -0
  3. package/dist/browserImages.d.ts.map +1 -0
  4. package/dist/browserImages.js +224 -0
  5. package/dist/browserImages.js.map +1 -0
  6. package/dist/client.d.ts +6 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +89 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/imagePrint.d.ts +69 -0
  11. package/dist/imagePrint.d.ts.map +1 -0
  12. package/dist/imagePrint.js +180 -0
  13. package/dist/imagePrint.js.map +1 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/niimbotProtocol.d.ts +38 -0
  19. package/dist/niimbotProtocol.d.ts.map +1 -0
  20. package/dist/niimbotProtocol.js +265 -0
  21. package/dist/niimbotProtocol.js.map +1 -0
  22. package/dist/transport.d.ts +5 -1
  23. package/dist/transport.d.ts.map +1 -1
  24. package/dist/transport.js +58 -0
  25. package/dist/transport.js.map +1 -1
  26. package/dist/types.d.ts +28 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/webBluetoothTransport.d.ts +137 -0
  29. package/dist/webBluetoothTransport.d.ts.map +1 -0
  30. package/dist/webBluetoothTransport.js +870 -0
  31. package/dist/webBluetoothTransport.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/browserImages.ts +323 -0
  34. package/src/client.ts +121 -0
  35. package/src/imagePrint.ts +306 -0
  36. package/src/index.ts +4 -0
  37. package/src/niimbotProtocol.ts +355 -0
  38. package/src/transport.ts +66 -0
  39. package/src/types.ts +44 -0
  40. package/src/web-bluetooth.d.ts +44 -0
  41. package/src/webBluetoothTransport.ts +1171 -0
@@ -0,0 +1,306 @@
1
+ import { createLabelBuilder } from "./builder.js";
2
+ import type { MaybePromise, PrintCallback, PrintDocument } from "./types.js";
3
+ import {
4
+ type ImageDataSource,
5
+ type LoadedImage,
6
+ loadImageSources,
7
+ } from "./browserImages.js";
8
+ import type { NiimbotPrintTask } from "./niimbotProtocol.js";
9
+
10
+ export interface FitRect {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface ImagePrintSettings {
18
+ widthMm: number;
19
+ heightMm: number;
20
+ orientation?: number;
21
+ density?: number;
22
+ speed?: number;
23
+ copies?: number;
24
+ paperType?: number;
25
+ printMode?: number;
26
+ marginMm?: number;
27
+ fitMode?: "contain" | "cover";
28
+ printTask?: "auto" | NiimbotPrintTask;
29
+ dpi?: number;
30
+ threshold?: number;
31
+ printMultiple?: number;
32
+ color?: number;
33
+ quality?: number;
34
+ epc?: string;
35
+ meta?: Record<string, unknown>;
36
+ callback?: Partial<PrintCallback>;
37
+ }
38
+
39
+ export interface ResolvedImagePrintSettings {
40
+ widthMm: number;
41
+ heightMm: number;
42
+ orientation: number;
43
+ density: number;
44
+ speed: number;
45
+ copies: number;
46
+ paperType: number;
47
+ printMode: number;
48
+ marginMm: number;
49
+ fitMode: "contain" | "cover";
50
+ printTask: "auto" | NiimbotPrintTask;
51
+ dpi: number;
52
+ threshold: number;
53
+ printMultiple: number;
54
+ color?: number;
55
+ quality?: number;
56
+ epc?: string;
57
+ meta: Record<string, unknown>;
58
+ callback?: Partial<PrintCallback>;
59
+ }
60
+
61
+ export interface PrintableImageClient {
62
+ startPrintJob(
63
+ density: number,
64
+ paperType: number,
65
+ printMode: number,
66
+ callback: PrintCallback,
67
+ ): MaybePromise<void>;
68
+ commitDocument(document: PrintDocument): MaybePromise<void>;
69
+ endPrintJob(): MaybePromise<boolean>;
70
+ cancelJob?(): MaybePromise<boolean>;
71
+ }
72
+
73
+ export interface PrintImageResult {
74
+ document: PrintDocument;
75
+ images: LoadedImage[];
76
+ }
77
+
78
+ export type ImagePrintSource =
79
+ | ImageDataSource
80
+ | ArrayLike<ImageDataSource>
81
+ | Iterable<ImageDataSource>;
82
+
83
+ export const DEFAULT_IMAGE_PRINT_SETTINGS: ResolvedImagePrintSettings = {
84
+ widthMm: 60,
85
+ heightMm: 40,
86
+ orientation: 0,
87
+ density: 3,
88
+ speed: 2,
89
+ copies: 1,
90
+ paperType: 1,
91
+ printMode: 1,
92
+ marginMm: 1,
93
+ fitMode: "contain",
94
+ printTask: "b1",
95
+ dpi: 0,
96
+ threshold: 127,
97
+ printMultiple: 8,
98
+ meta: {},
99
+ };
100
+
101
+ const imagePrintQueues = new WeakMap<PrintableImageClient, Promise<void>>();
102
+
103
+ function finiteNumber(value: number | undefined, fallback: number): number {
104
+ return Number.isFinite(value) ? Number(value) : fallback;
105
+ }
106
+
107
+ function positiveNumber(name: string, value: number): number {
108
+ if (!Number.isFinite(value) || value <= 0) {
109
+ throw new Error(`${name} must be a positive number.`);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function positiveInteger(value: number, fallback: number): number {
115
+ if (!Number.isFinite(value)) {
116
+ return fallback;
117
+ }
118
+ return Math.max(1, Math.trunc(value));
119
+ }
120
+
121
+ function normalizeSettings(settings: ImagePrintSettings): ResolvedImagePrintSettings {
122
+ const widthMm = positiveNumber("widthMm", finiteNumber(settings.widthMm, DEFAULT_IMAGE_PRINT_SETTINGS.widthMm));
123
+ const heightMm = positiveNumber("heightMm", finiteNumber(settings.heightMm, DEFAULT_IMAGE_PRINT_SETTINGS.heightMm));
124
+ const printTask = settings.printTask ?? DEFAULT_IMAGE_PRINT_SETTINGS.printTask;
125
+ const printMultiple = positiveInteger(
126
+ finiteNumber(settings.printMultiple, printTask === "b1" ? 8 : 1),
127
+ printTask === "b1" ? 8 : 1,
128
+ );
129
+ const resolved: ResolvedImagePrintSettings = {
130
+ widthMm,
131
+ heightMm,
132
+ orientation: finiteNumber(settings.orientation, DEFAULT_IMAGE_PRINT_SETTINGS.orientation),
133
+ density: finiteNumber(settings.density, DEFAULT_IMAGE_PRINT_SETTINGS.density),
134
+ speed: finiteNumber(settings.speed, DEFAULT_IMAGE_PRINT_SETTINGS.speed),
135
+ copies: positiveInteger(finiteNumber(settings.copies, DEFAULT_IMAGE_PRINT_SETTINGS.copies), DEFAULT_IMAGE_PRINT_SETTINGS.copies),
136
+ paperType: positiveInteger(finiteNumber(settings.paperType, DEFAULT_IMAGE_PRINT_SETTINGS.paperType), DEFAULT_IMAGE_PRINT_SETTINGS.paperType),
137
+ printMode: positiveInteger(finiteNumber(settings.printMode, DEFAULT_IMAGE_PRINT_SETTINGS.printMode), DEFAULT_IMAGE_PRINT_SETTINGS.printMode),
138
+ marginMm: Math.max(0, finiteNumber(settings.marginMm, DEFAULT_IMAGE_PRINT_SETTINGS.marginMm)),
139
+ fitMode: settings.fitMode ?? DEFAULT_IMAGE_PRINT_SETTINGS.fitMode,
140
+ printTask,
141
+ dpi: Math.max(0, finiteNumber(settings.dpi, DEFAULT_IMAGE_PRINT_SETTINGS.dpi)),
142
+ threshold: Math.max(0, Math.min(255, finiteNumber(settings.threshold, DEFAULT_IMAGE_PRINT_SETTINGS.threshold))),
143
+ printMultiple,
144
+ meta: { ...(settings.meta ?? {}) },
145
+ ...(Number.isFinite(settings.color) ? { color: Number(settings.color) } : {}),
146
+ ...(Number.isFinite(settings.quality) ? { quality: Number(settings.quality) } : {}),
147
+ ...(settings.epc ? { epc: settings.epc } : {}),
148
+ ...(settings.callback ? { callback: settings.callback } : {}),
149
+ };
150
+ return resolved;
151
+ }
152
+
153
+ function toPrintCallback(callback?: Partial<PrintCallback>): PrintCallback {
154
+ return {
155
+ onProgress: callback?.onProgress ?? (() => undefined),
156
+ onError: callback?.onError ?? (() => undefined),
157
+ onBufferFree: callback?.onBufferFree ?? (() => undefined),
158
+ onCancelJob: callback?.onCancelJob ?? (() => undefined),
159
+ };
160
+ }
161
+
162
+ function queueImagePrintJob<T>(
163
+ printer: PrintableImageClient,
164
+ run: () => Promise<T>,
165
+ ): Promise<T> {
166
+ const previous = imagePrintQueues.get(printer) ?? Promise.resolve();
167
+ const queued = previous.catch(() => undefined).then(run);
168
+ imagePrintQueues.set(
169
+ printer,
170
+ queued.then(
171
+ () => undefined,
172
+ () => undefined,
173
+ ),
174
+ );
175
+ return queued;
176
+ }
177
+
178
+ export function fitRect(
179
+ boxWidth: number,
180
+ boxHeight: number,
181
+ contentWidth: number,
182
+ contentHeight: number,
183
+ mode: "contain" | "cover",
184
+ ): FitRect {
185
+ const safeBoxWidth = Math.max(0, boxWidth);
186
+ const safeBoxHeight = Math.max(0, boxHeight);
187
+ const safeContentWidth = Math.max(1, contentWidth);
188
+ const safeContentHeight = Math.max(1, contentHeight);
189
+ const scale =
190
+ mode === "cover"
191
+ ? Math.max(safeBoxWidth / safeContentWidth, safeBoxHeight / safeContentHeight)
192
+ : Math.min(safeBoxWidth / safeContentWidth, safeBoxHeight / safeContentHeight);
193
+ const width = safeContentWidth * scale;
194
+ const height = safeContentHeight * scale;
195
+
196
+ return {
197
+ x: (safeBoxWidth - width) / 2,
198
+ y: (safeBoxHeight - height) / 2,
199
+ width,
200
+ height,
201
+ };
202
+ }
203
+
204
+ export function buildPrintDocument(images: Iterable<LoadedImage>, settings: ImagePrintSettings): PrintDocument {
205
+ const loadedImages = Array.from(images);
206
+ if (!loadedImages.length) {
207
+ throw new Error("No images were provided for printing.");
208
+ }
209
+
210
+ const resolved = normalizeSettings(settings);
211
+ const builder = createLabelBuilder("mm");
212
+ builder.startJob(resolved.density, resolved.paperType, resolved.printMode);
213
+ builder.setTotalPrintQuantity(resolved.copies);
214
+
215
+ const margin = Math.max(0, resolved.marginMm);
216
+ const contentWidth = Math.max(0, resolved.widthMm - margin * 2);
217
+ const contentHeight = Math.max(0, resolved.heightMm - margin * 2);
218
+
219
+ for (const image of loadedImages) {
220
+ builder.drawEmptyLabel(resolved.widthMm, resolved.heightMm, resolved.orientation, []);
221
+ const box = fitRect(contentWidth, contentHeight, image.width, image.height, resolved.fitMode);
222
+ builder.drawLabelImage(image.dataUrl, margin + box.x, margin + box.y, box.width, box.height, {
223
+ actualSize: false,
224
+ rotation: 0,
225
+ scale: 1,
226
+ threshold: 0,
227
+ });
228
+ }
229
+
230
+ const document = builder.toDocument();
231
+ document.job.density = resolved.density;
232
+ document.job.paperType = resolved.paperType;
233
+ document.job.printMode = resolved.printMode;
234
+ document.job.totalQuantity = resolved.copies;
235
+ document.job.printMultiple = resolved.printMultiple;
236
+ if (resolved.color !== undefined) {
237
+ document.job.color = resolved.color;
238
+ }
239
+ if (resolved.quality !== undefined) {
240
+ document.job.quality = resolved.quality;
241
+ }
242
+ if (resolved.epc) {
243
+ document.job.epc = resolved.epc;
244
+ }
245
+ document.job.meta = {
246
+ ...resolved.meta,
247
+ widthMm: resolved.widthMm,
248
+ heightMm: resolved.heightMm,
249
+ speed: resolved.speed,
250
+ marginMm: resolved.marginMm,
251
+ fitMode: resolved.fitMode,
252
+ printTask: resolved.printTask,
253
+ dpi: resolved.dpi,
254
+ threshold: resolved.threshold,
255
+ imageCount: loadedImages.length,
256
+ };
257
+
258
+ return document;
259
+ }
260
+
261
+ export async function buildPrintDocumentFromSources(
262
+ sources: ImagePrintSource,
263
+ settings: ImagePrintSettings,
264
+ ): Promise<PrintDocument> {
265
+ const images = await loadImageSources(sources);
266
+ return buildPrintDocument(images, settings);
267
+ }
268
+
269
+ export async function printImageData(
270
+ printer: PrintableImageClient,
271
+ sources: ImagePrintSource,
272
+ settings: ImagePrintSettings,
273
+ ): Promise<PrintImageResult> {
274
+ return queueImagePrintJob(printer, async () => {
275
+ const resolved = normalizeSettings(settings);
276
+ const images = await loadImageSources(sources);
277
+ const document = buildPrintDocument(images, resolved);
278
+ const callback = toPrintCallback(resolved.callback);
279
+ let started = false;
280
+
281
+ try {
282
+ await printer.startPrintJob(resolved.density, resolved.paperType, resolved.printMode, callback);
283
+ started = true;
284
+ await printer.commitDocument(document);
285
+ await printer.endPrintJob();
286
+ return { document, images };
287
+ } catch (error) {
288
+ if (started && printer.cancelJob) {
289
+ try {
290
+ await printer.cancelJob();
291
+ } catch {
292
+ // Keep the original print error.
293
+ }
294
+ }
295
+ throw error;
296
+ }
297
+ });
298
+ }
299
+
300
+ export async function printCanvases(
301
+ printer: PrintableImageClient,
302
+ canvases: ArrayLike<HTMLCanvasElement | OffscreenCanvas> | Iterable<HTMLCanvasElement | OffscreenCanvas>,
303
+ settings: ImagePrintSettings,
304
+ ): Promise<PrintImageResult> {
305
+ return printImageData(printer, Array.from(canvases), settings);
306
+ }
package/src/index.ts CHANGED
@@ -3,3 +3,7 @@ export * from "./types.js";
3
3
  export * from "./builder.js";
4
4
  export * from "./transport.js";
5
5
  export * from "./client.js";
6
+ export * from "./browserImages.js";
7
+ export * from "./imagePrint.js";
8
+ export * from "./niimbotProtocol.js";
9
+ export * from "./webBluetoothTransport.js";
@@ -0,0 +1,355 @@
1
+ import type { CanvasCommand, PrintDocument, PrintPage } from "./types.js";
2
+
3
+ export type NiimbotPrintTask = "v4" | "b1";
4
+
5
+ export interface NiimbotPrinterProfile {
6
+ modelId?: number;
7
+ protocolVersion?: number;
8
+ label: string;
9
+ task: NiimbotPrintTask;
10
+ dpi: number;
11
+ printheadWidthPx: number;
12
+ paceMs: number;
13
+ }
14
+
15
+ export interface NiimbotGeometry {
16
+ widthPx: number;
17
+ heightPx: number;
18
+ dpi: number;
19
+ printheadWidthPx: number;
20
+ }
21
+
22
+ export interface NiimbotRasterPage {
23
+ widthPx: number;
24
+ heightPx: number;
25
+ stride: number;
26
+ rows: Uint8Array[];
27
+ blackCounts: number[];
28
+ }
29
+
30
+ const MM_PER_INCH = 25.4;
31
+
32
+ const modelProfiles = new Map<number, NiimbotPrinterProfile>([
33
+ [512, { modelId: 512, label: "D11", task: "b1", dpi: 203, printheadWidthPx: 96, paceMs: 10 }],
34
+ [513, { modelId: 513, label: "FUST", task: "b1", dpi: 203, printheadWidthPx: 96, paceMs: 10 }],
35
+ [514, { modelId: 514, label: "D11S", task: "b1", dpi: 203, printheadWidthPx: 96, paceMs: 10 }],
36
+ [528, { modelId: 528, label: "D11_H", task: "v4", dpi: 300, printheadWidthPx: 142, paceMs: 0 }],
37
+ [785, { modelId: 785, label: "B21_PRO", task: "v4", dpi: 300, printheadWidthPx: 591, paceMs: 0 }],
38
+ [4096, { modelId: 4096, label: "B1", task: "b1", dpi: 203, printheadWidthPx: 384, paceMs: 10 }],
39
+ [4097, { modelId: 4097, label: "B1 Pro", task: "v4", dpi: 300, printheadWidthPx: 567, paceMs: 0 }],
40
+ [4098, { modelId: 4098, label: "B1 SE", task: "b1", dpi: 203, printheadWidthPx: 384, paceMs: 10 }],
41
+ [4608, { modelId: 4608, label: "M2-H", task: "b1", dpi: 300, printheadWidthPx: 567, paceMs: 10 }],
42
+ [2304, { modelId: 2304, label: "D110", task: "b1", dpi: 203, printheadWidthPx: 96, paceMs: 10 }],
43
+ [2305, { modelId: 2305, label: "HI_D110", task: "b1", dpi: 203, printheadWidthPx: 120, paceMs: 10 }],
44
+ [2320, { modelId: 2320, label: "D110_M", task: "v4", dpi: 203, printheadWidthPx: 96, paceMs: 0 }],
45
+ ]);
46
+
47
+ export function createFrame(cmd: number, data: number[] | Uint8Array = []): Uint8Array {
48
+ const payload = Array.from(data);
49
+ const packet = new Uint8Array(7 + payload.length);
50
+ packet[0] = 0x55;
51
+ packet[1] = 0x55;
52
+ packet[2] = cmd & 0xff;
53
+ packet[3] = payload.length & 0xff;
54
+ let checksum = packet[2] ^ packet[3];
55
+
56
+ for (let index = 0; index < payload.length; index += 1) {
57
+ const value = payload[index] & 0xff;
58
+ packet[4 + index] = value;
59
+ checksum ^= value;
60
+ }
61
+
62
+ packet[4 + payload.length] = checksum & 0xff;
63
+ packet[5 + payload.length] = 0xaa;
64
+ packet[6 + payload.length] = 0xaa;
65
+ return packet;
66
+ }
67
+
68
+ export function createConnectFrame(): Uint8Array {
69
+ const frame = createFrame(0xc1, [0x01]);
70
+ const packet = new Uint8Array(frame.length + 1);
71
+ packet[0] = 0x03;
72
+ packet.set(frame, 1);
73
+ return packet;
74
+ }
75
+
76
+ export function parseFrame(value: DataView): { cmd: number; data: number[] } | null {
77
+ if (value.byteLength < 7) {
78
+ return null;
79
+ }
80
+ if (value.getUint8(0) !== 0x55 || value.getUint8(1) !== 0x55) {
81
+ return null;
82
+ }
83
+ const cmd = value.getUint8(2);
84
+ const length = value.getUint8(3);
85
+ if (value.byteLength < length + 7) {
86
+ return null;
87
+ }
88
+
89
+ const data: number[] = [];
90
+ let checksum = cmd ^ length;
91
+ for (let index = 0; index < length; index += 1) {
92
+ const byte = value.getUint8(4 + index);
93
+ data.push(byte);
94
+ checksum ^= byte;
95
+ }
96
+
97
+ const packetChecksum = value.getUint8(4 + length);
98
+ if ((checksum & 0xff) !== packetChecksum) {
99
+ return null;
100
+ }
101
+ return { cmd, data };
102
+ }
103
+
104
+ export function identifyProfile(modelId?: number, protocolVersion?: number): NiimbotPrinterProfile | null {
105
+ if (modelId && modelProfiles.has(modelId)) {
106
+ const profile = modelProfiles.get(modelId)!;
107
+ return protocolVersion === undefined ? { ...profile } : { ...profile, protocolVersion };
108
+ }
109
+
110
+ if (protocolVersion === 3) {
111
+ return {
112
+ protocolVersion,
113
+ label: "Protocol 3 printer",
114
+ task: "b1",
115
+ dpi: 203,
116
+ printheadWidthPx: 384,
117
+ paceMs: 10,
118
+ };
119
+ }
120
+
121
+ if (protocolVersion && protocolVersion >= 4) {
122
+ return {
123
+ protocolVersion,
124
+ label: "Protocol 4+ printer",
125
+ task: "v4",
126
+ dpi: 300,
127
+ printheadWidthPx: 584,
128
+ paceMs: 0,
129
+ };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ export function fallbackProfile(task: "auto" | NiimbotPrintTask, dpi: number): NiimbotPrinterProfile {
136
+ if (task === "b1") {
137
+ const safeDpi = dpi || 203;
138
+ return {
139
+ label: "Manual B1-compatible",
140
+ task: "b1",
141
+ dpi: safeDpi,
142
+ printheadWidthPx: safeDpi >= 300 ? 567 : 384,
143
+ paceMs: 10,
144
+ };
145
+ }
146
+
147
+ return {
148
+ label: task === "auto" ? "Auto fallback V4" : "Manual V4",
149
+ task: "v4",
150
+ dpi: dpi || 300,
151
+ printheadWidthPx: dpi && dpi < 250 ? 384 : 584,
152
+ paceMs: 0,
153
+ };
154
+ }
155
+
156
+ export function geometryFromPage(page: PrintPage, profile: NiimbotPrinterProfile): NiimbotGeometry {
157
+ const widthFromMm = Math.max(1, Math.round((page.width * profile.dpi) / MM_PER_INCH));
158
+ const heightPx = Math.max(1, Math.round((page.height * profile.dpi) / MM_PER_INCH));
159
+ return {
160
+ widthPx: Math.min(widthFromMm, profile.printheadWidthPx),
161
+ heightPx,
162
+ dpi: profile.dpi,
163
+ printheadWidthPx: profile.printheadWidthPx,
164
+ };
165
+ }
166
+
167
+ export async function rasterizeDocumentPage(
168
+ page: PrintPage,
169
+ printDocument: PrintDocument,
170
+ profile: NiimbotPrinterProfile,
171
+ ): Promise<NiimbotRasterPage> {
172
+ const geometry = geometryFromPage(page, profile);
173
+ const element = globalThis.document?.createElement("canvas");
174
+ if (!element) {
175
+ throw new Error("Canvas is required to rasterize the print image in the browser.");
176
+ }
177
+
178
+ element.width = geometry.widthPx;
179
+ element.height = geometry.heightPx;
180
+ const context = element.getContext("2d", { willReadFrequently: true });
181
+ if (!context) {
182
+ throw new Error("Failed to create a 2D canvas context for printing.");
183
+ }
184
+
185
+ context.fillStyle = "#ffffff";
186
+ context.fillRect(0, 0, element.width, element.height);
187
+
188
+ for (const command of page.commands) {
189
+ await drawCommand(context, command, page, profile);
190
+ }
191
+
192
+ const threshold = Number(printDocument.job.meta?.threshold ?? 128);
193
+ const imageData = context.getImageData(0, 0, element.width, element.height);
194
+ return encodeMonochrome(imageData, element.width, element.height, threshold);
195
+ }
196
+
197
+ export function createRowFrames(page: NiimbotRasterPage): Uint8Array[] {
198
+ const frames: Uint8Array[] = [];
199
+ let rowIndex = 0;
200
+
201
+ while (rowIndex < page.heightPx) {
202
+ const row = page.rows[rowIndex];
203
+ const blackCount = page.blackCounts[rowIndex];
204
+ let run = 1;
205
+ while (
206
+ rowIndex + run < page.heightPx &&
207
+ run < 200 &&
208
+ blackCount === page.blackCounts[rowIndex + run] &&
209
+ rowsEqual(row, page.rows[rowIndex + run])
210
+ ) {
211
+ run += 1;
212
+ }
213
+
214
+ if (blackCount === 0) {
215
+ frames.push(createFrame(0x84, [rowIndex >> 8, rowIndex & 0xff, run]));
216
+ } else {
217
+ frames.push(
218
+ createFrame(0x85, [
219
+ rowIndex >> 8,
220
+ rowIndex & 0xff,
221
+ 0x00,
222
+ blackCount & 0xff,
223
+ (blackCount >> 8) & 0xff,
224
+ run,
225
+ ...row,
226
+ ]),
227
+ );
228
+ }
229
+ rowIndex += run;
230
+ }
231
+
232
+ return frames;
233
+ }
234
+
235
+ export function parseProtocolVersion(data: number[]): number | undefined {
236
+ if (data.length >= 13) {
237
+ const version = data[11] * 100 + data[12];
238
+ if (version >= 204 && version <= 299) {
239
+ return 3;
240
+ }
241
+ if (version >= 300 && version <= 301) {
242
+ return 4;
243
+ }
244
+ if (version >= 302) {
245
+ return 5;
246
+ }
247
+ }
248
+ return undefined;
249
+ }
250
+
251
+ export function parseModelId(data: number[]): number | undefined {
252
+ if (data.length >= 2) {
253
+ return (data[0] << 8) | data[1];
254
+ }
255
+ if (data.length === 1) {
256
+ return data[0] << 8;
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ async function drawCommand(
262
+ context: CanvasRenderingContext2D,
263
+ command: CanvasCommand,
264
+ page: PrintPage,
265
+ profile: NiimbotPrinterProfile,
266
+ ): Promise<void> {
267
+ if (command.kind !== "drawLabelImage") {
268
+ return;
269
+ }
270
+
271
+ const payload = command.payload as {
272
+ source?: unknown;
273
+ x?: unknown;
274
+ y?: unknown;
275
+ width?: unknown;
276
+ height?: unknown;
277
+ };
278
+ if (typeof payload.source !== "string") {
279
+ return;
280
+ }
281
+
282
+ const image = await loadImage(payload.source);
283
+ const scale = profile.dpi / MM_PER_INCH;
284
+ const x = clampPixel(Number(payload.x ?? 0) * scale, 0, context.canvas.width);
285
+ const y = clampPixel(Number(payload.y ?? 0) * scale, 0, context.canvas.height);
286
+ const width = clampPixel(Number(payload.width ?? page.width) * scale, 0, context.canvas.width - x);
287
+ const height = clampPixel(Number(payload.height ?? page.height) * scale, 0, context.canvas.height - y);
288
+
289
+ if (width > 0 && height > 0) {
290
+ context.drawImage(image, x, y, width, height);
291
+ }
292
+ }
293
+
294
+ function encodeMonochrome(
295
+ imageData: ImageData,
296
+ width: number,
297
+ height: number,
298
+ threshold: number,
299
+ ): NiimbotRasterPage {
300
+ const stride = Math.ceil(width / 8);
301
+ const rows: Uint8Array[] = [];
302
+ const blackCounts: number[] = [];
303
+
304
+ for (let y = 0; y < height; y += 1) {
305
+ const row = new Uint8Array(stride);
306
+ let total = 0;
307
+
308
+ for (let x = 0; x < width; x += 1) {
309
+ const offset = (y * width + x) * 4;
310
+ const alpha = imageData.data[offset + 3];
311
+ const luminance =
312
+ imageData.data[offset] * 0.299 +
313
+ imageData.data[offset + 1] * 0.587 +
314
+ imageData.data[offset + 2] * 0.114;
315
+ const black = alpha > 32 && luminance < threshold;
316
+ if (black) {
317
+ row[Math.floor(x / 8)] |= 0x80 >> (x % 8);
318
+ total += 1;
319
+ }
320
+ }
321
+
322
+ rows.push(row);
323
+ blackCounts.push(total);
324
+ }
325
+
326
+ return { widthPx: width, heightPx: height, stride, rows, blackCounts };
327
+ }
328
+
329
+ function loadImage(source: string): Promise<HTMLImageElement> {
330
+ return new Promise((resolve, reject) => {
331
+ const image = new Image();
332
+ image.onload = () => resolve(image);
333
+ image.onerror = () => reject(new Error("Failed to decode print image."));
334
+ image.src = source;
335
+ });
336
+ }
337
+
338
+ function rowsEqual(left: Uint8Array, right: Uint8Array): boolean {
339
+ if (left.length !== right.length) {
340
+ return false;
341
+ }
342
+ for (let index = 0; index < left.length; index += 1) {
343
+ if (left[index] !== right[index]) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+
350
+ function clampPixel(value: number, min: number, max: number): number {
351
+ if (!Number.isFinite(value)) {
352
+ return min;
353
+ }
354
+ return Math.max(min, Math.min(max, Math.round(value)));
355
+ }