@bobfrankston/brother-label 1.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.
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env node
2
+ import { Jimp, loadFont, measureText, measureTextHeight, HorizontalAlign, VerticalAlign } from "jimp";
3
+ import { program } from "commander";
4
+ import { exec, spawn } from "child_process";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import * as os from "os";
8
+ import { renderHtmlFile, closeBrowser } from "./render.js";
9
+ import QRCode from "qrcode";
10
+
11
+ const __dirname = import.meta.dirname;
12
+
13
+ interface PrintOptions {
14
+ printer: string;
15
+ fontSize: number;
16
+ width: number;
17
+ height: number;
18
+ tapeMm: number;
19
+ }
20
+
21
+ interface Config {
22
+ defaultTape?: string;
23
+ defaultPrinter?: string;
24
+ }
25
+
26
+ const CONFIG_PATH = path.join(os.homedir(), ".brother-label.json");
27
+
28
+ function loadConfig(): Config {
29
+ try {
30
+ if (fs.existsSync(CONFIG_PATH)) {
31
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
32
+ }
33
+ } catch (err) {
34
+ // Ignore config errors
35
+ }
36
+ return {};
37
+ }
38
+
39
+ function saveConfig(config: Config): void {
40
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
41
+ }
42
+
43
+ // Available font sizes in jimp
44
+ const FONT_SIZES = [128, 64, 32, 16, 14, 12, 10, 8];
45
+
46
+ async function loadFontBySize(size: number): Promise<any> {
47
+ const fontDir = path.join(__dirname, "node_modules/@jimp/plugin-print/fonts/open-sans");
48
+ const fontFolder = `open-sans-${size}-black`;
49
+ const fontPath = path.join(fontDir, fontFolder, `${fontFolder}.fnt`);
50
+ return loadFont(fontPath);
51
+ }
52
+
53
+ async function createLabelImage(text: string, options: PrintOptions): Promise<Buffer> {
54
+ const lines = text.split("\\n").join("\n");
55
+ const targetHeight = options.height;
56
+
57
+ // Use largest font for best quality, then scale down
58
+ let font = await loadFontBySize(128);
59
+
60
+ // Render text at large size first
61
+ const textWidth = measureText(font, lines);
62
+ const textHeight = measureTextHeight(font, lines, textWidth + 100);
63
+
64
+ // Create image for text with small padding
65
+ const padding = 10;
66
+ const imgWidth = textWidth + padding * 2;
67
+ const imgHeight = textHeight + padding * 2;
68
+
69
+ const tempImage = new Jimp({ width: imgWidth, height: imgHeight, color: 0xffffffff });
70
+
71
+ tempImage.print({
72
+ font: font,
73
+ x: padding,
74
+ y: padding,
75
+ text: lines,
76
+ });
77
+
78
+ // Scale to fit tape height (tape sizes already account for printable area)
79
+ const scale = targetHeight / imgHeight;
80
+ const finalWidth = Math.round(imgWidth * scale);
81
+ const finalHeight = Math.round(imgHeight * scale);
82
+
83
+ tempImage.resize({ w: finalWidth, h: finalHeight });
84
+
85
+ // Create final image sized to content with small horizontal padding
86
+ const hPadding = Math.round(targetHeight * 0.2); // ~20% of height as side margins
87
+ const outputWidth = finalWidth + hPadding * 2;
88
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
89
+
90
+ // Center both horizontally and vertically
91
+ const xOffset = hPadding;
92
+ const yOffset = Math.round((targetHeight - finalHeight) / 2);
93
+ image.composite(tempImage, xOffset, yOffset);
94
+
95
+ return image.getBuffer("image/png");
96
+ }
97
+
98
+ async function createQrLabelImage(data: string, options: PrintOptions, labelText?: string): Promise<Buffer> {
99
+ const targetHeight = options.height;
100
+ const qrSize = Math.floor(targetHeight * 0.95); // QR code is 95% of tape height
101
+
102
+ // Margins in mm converted to pixels at PRINT_DPI
103
+ // Left margin ~2mm, right margin ~4mm to account for printer feed
104
+ const pxPerMm = PRINT_DPI / 25.4;
105
+ const leftMargin = Math.round(2 * pxPerMm);
106
+ const rightMargin = Math.round(4 * pxPerMm);
107
+
108
+ // Generate QR code as PNG buffer at high resolution
109
+ const qrBuffer = await QRCode.toBuffer(data, {
110
+ type: "png",
111
+ width: qrSize,
112
+ margin: 0,
113
+ errorCorrectionLevel: "M",
114
+ color: { dark: "#000000", light: "#ffffff" },
115
+ });
116
+
117
+ const qrImage = await Jimp.read(qrBuffer);
118
+
119
+ if (!labelText) {
120
+ // QR only - create minimal label with margins
121
+ const outputWidth = leftMargin + qrSize + rightMargin;
122
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
123
+ const yOffset = Math.floor((targetHeight - qrSize) / 2);
124
+ image.composite(qrImage, leftMargin, yOffset);
125
+ return image.getBuffer("image/png");
126
+ }
127
+
128
+ // QR + text label
129
+ const font = await loadFontBySize(64);
130
+ const textWidth = measureText(font, labelText);
131
+ const textHeight = measureTextHeight(font, labelText, textWidth + 50);
132
+
133
+ // Scale text to fit beside QR
134
+ const maxTextHeight = targetHeight * 0.8;
135
+ const textScale = Math.min(1, maxTextHeight / textHeight);
136
+ const scaledTextWidth = Math.round(textWidth * textScale);
137
+ const scaledTextHeight = Math.round(textHeight * textScale);
138
+
139
+ // Create text image
140
+ const textImg = new Jimp({ width: textWidth + 20, height: textHeight + 20, color: 0xffffffff });
141
+ textImg.print({ font, x: 10, y: 10, text: labelText });
142
+ if (textScale < 1) {
143
+ textImg.resize({ w: scaledTextWidth, h: scaledTextHeight });
144
+ }
145
+
146
+ // Compose final image: QR on left, text on right
147
+ const gap = Math.floor(targetHeight * 0.15);
148
+ const outputWidth = leftMargin + qrSize + gap + scaledTextWidth + rightMargin;
149
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
150
+
151
+ const qrY = Math.floor((targetHeight - qrSize) / 2);
152
+ image.composite(qrImage, leftMargin, qrY);
153
+
154
+ const textY = Math.floor((targetHeight - scaledTextHeight) / 2);
155
+ image.composite(textImg, leftMargin + qrSize + gap, textY);
156
+
157
+ return image.getBuffer("image/png");
158
+ }
159
+
160
+ async function printImage(imagePath: string, printerName: string, tapeMm: number): Promise<void> {
161
+ // Map tape mm to Brother CustomMediaSize for PrintTicket
162
+ const mediaOptions: Record<number, { name: string; width: number }> = {
163
+ 6: { name: "CustomMediaSize257", width: 5900 },
164
+ 9: { name: "CustomMediaSize258", width: 9000 },
165
+ 12: { name: "CustomMediaSize259", width: 11900 },
166
+ 18: { name: "CustomMediaSize260", width: 18100 },
167
+ 24: { name: "CustomMediaSize261", width: 24000 },
168
+ };
169
+ const media = mediaOptions[tapeMm] || mediaOptions[12];
170
+
171
+ return new Promise((resolve, reject) => {
172
+ const psScript = `
173
+ Add-Type -AssemblyName System.Drawing
174
+ Add-Type -AssemblyName System.Printing
175
+ Add-Type -AssemblyName ReachFramework
176
+ Add-Type -AssemblyName PresentationCore
177
+
178
+ $img = [System.Drawing.Image]::FromFile('${imagePath.replace(/\\/g, "\\\\")}')
179
+
180
+ # Calculate label dimensions in microns (using ${PRINT_DPI} DPI source images)
181
+ $labelLengthMicrons = [int]($img.Width / ${PRINT_DPI}.0 * 25400) + 3000
182
+ $tapeWidthMicrons = ${media.width}
183
+
184
+ # Image dimensions in WPF units (1/96 inch) - convert from ${PRINT_DPI} DPI source
185
+ $imgWidthWpf = $img.Width / ${PRINT_DPI}.0 * 96
186
+ $imgHeightWpf = $img.Height / ${PRINT_DPI}.0 * 96
187
+
188
+ Write-Host "Using ${tapeMm}mm tape, label length: $([int]($labelLengthMicrons/1000))mm"
189
+
190
+ # Build PrintTicket XML
191
+ $ticketXml = @"
192
+ <?xml version="1.0" encoding="UTF-8"?>
193
+ <psf:PrintTicket xmlns:psf="http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework"
194
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1"
195
+ xmlns:psk="http://schemas.microsoft.com/windows/2003/08/printing/printschemakeywords"
196
+ xmlns:ns0001="http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords">
197
+ <psf:Feature name="psk:PageMediaSize">
198
+ <psf:Option name="ns0001:${media.name}">
199
+ <psf:ScoredProperty name="psk:MediaSizeWidth">
200
+ <psf:Value xsi:type="xsd:integer">${media.width}</psf:Value>
201
+ </psf:ScoredProperty>
202
+ <psf:ScoredProperty name="psk:MediaSizeHeight">
203
+ <psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight" />
204
+ </psf:ScoredProperty>
205
+ </psf:Option>
206
+ </psf:Feature>
207
+ <psf:ParameterInit name="psk:PageMediaSizeMediaSizeHeight">
208
+ <psf:Value xsi:type="xsd:integer">$labelLengthMicrons</psf:Value>
209
+ </psf:ParameterInit>
210
+ <psf:Feature name="psk:PageOrientation">
211
+ <psf:Option name="psk:Landscape" />
212
+ </psf:Feature>
213
+ <psf:Feature name="ns0001:PageRollFeedToEndOfSheet">
214
+ <psf:Option name="ns0001:FeedToImageEdge" />
215
+ </psf:Feature>
216
+ <psf:Feature name="psk:PageMediaType">
217
+ <psf:Option name="psk:Label" />
218
+ </psf:Feature>
219
+ <psf:Feature name="ns0001:JobRollCutSheet">
220
+ <psf:Option name="ns0001:CutSheet" />
221
+ </psf:Feature>
222
+ </psf:PrintTicket>
223
+ "@
224
+
225
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ticketXml)
226
+ $memStream = New-Object System.IO.MemoryStream(,$xmlBytes)
227
+ $ticket = New-Object System.Printing.PrintTicket($memStream)
228
+
229
+ # Get print queue and create XPS document writer with our ticket
230
+ $server = New-Object System.Printing.LocalPrintServer
231
+ $queue = $server.GetPrintQueue('${printerName}')
232
+
233
+ # Create temp XPS file
234
+ $xpsPath = [System.IO.Path]::GetTempFileName() + ".xps"
235
+
236
+ # Page size in WPF units (96 dpi) - convert from microns
237
+ $pageWidth = $labelLengthMicrons / 25400.0 * 96 # length is horizontal
238
+ $pageHeight = $tapeWidthMicrons / 25400.0 * 96 # tape width is vertical
239
+
240
+ # Create XPS document
241
+ $package = [System.IO.Packaging.Package]::Open($xpsPath, [System.IO.FileMode]::Create)
242
+ $xpsDoc = New-Object System.Windows.Xps.Packaging.XpsDocument($package)
243
+ $writer = [System.Windows.Xps.Packaging.XpsDocument]::CreateXpsDocumentWriter($xpsDoc)
244
+
245
+ # Create visual with image
246
+ $visual = New-Object System.Windows.Media.DrawingVisual
247
+ $dc = $visual.RenderOpen()
248
+
249
+ # Load image as WPF bitmap
250
+ $bitmapImg = New-Object System.Windows.Media.Imaging.BitmapImage
251
+ $bitmapImg.BeginInit()
252
+ $bitmapImg.UriSource = New-Object System.Uri('${imagePath.replace(/\\/g, "\\\\")}')
253
+ $bitmapImg.EndInit()
254
+
255
+ # Draw image centered vertically on page
256
+ $yOffset = ($pageHeight - $imgHeightWpf) / 2
257
+ $rect = New-Object System.Windows.Rect(0, $yOffset, $imgWidthWpf, $imgHeightWpf)
258
+ $dc.DrawImage($bitmapImg, $rect)
259
+ $dc.Close()
260
+
261
+ # Write to XPS with page size
262
+ $pageSize = New-Object System.Printing.PageMediaSize($pageWidth, $pageHeight)
263
+ $writer.Write($visual, $ticket)
264
+
265
+ $xpsDoc.Close()
266
+ $package.Close()
267
+ $img.Dispose()
268
+
269
+ # Print the XPS file with our ticket
270
+ $xpsDocForPrint = New-Object System.Windows.Xps.Packaging.XpsDocument($xpsPath, [System.IO.FileAccess]::Read)
271
+ $seq = $xpsDocForPrint.GetFixedDocumentSequence()
272
+
273
+ $xpsWriter = [System.Printing.PrintQueue]::CreateXpsDocumentWriter($queue)
274
+ $xpsWriter.Write($seq, $ticket)
275
+
276
+ $xpsDocForPrint.Close()
277
+ Remove-Item $xpsPath -ErrorAction SilentlyContinue
278
+
279
+ Write-Host "Print complete"
280
+ `;
281
+
282
+ const ps = spawn("powershell", ["-Command", psScript], { stdio: "pipe" });
283
+
284
+ let stdout = "";
285
+ let stderr = "";
286
+ ps.stdout.on("data", (data) => {
287
+ stdout += data.toString();
288
+ });
289
+ ps.stderr.on("data", (data) => {
290
+ stderr += data.toString();
291
+ });
292
+
293
+ ps.on("close", (code) => {
294
+ if (stdout) console.log(stdout.trim());
295
+ if (code === 0) {
296
+ resolve();
297
+ } else {
298
+ reject(new Error(`Print failed: ${stderr}`));
299
+ }
300
+ });
301
+
302
+ ps.on("error", (err) => {
303
+ reject(new Error(`Failed to print: ${err.message}`));
304
+ });
305
+ });
306
+ }
307
+
308
+ async function printLabel(text: string, options: PrintOptions): Promise<void> {
309
+ // Create temp file for the image
310
+ const tempDir = os.tmpdir();
311
+ const tempFile = path.join(tempDir, `label-${Date.now()}.png`);
312
+
313
+ try {
314
+ const imageBuffer = await createLabelImage(text, options);
315
+ fs.writeFileSync(tempFile, imageBuffer);
316
+
317
+ await printImage(tempFile, options.printer, options.tapeMm);
318
+ } finally {
319
+ // Clean up temp file
320
+ if (fs.existsSync(tempFile)) {
321
+ fs.unlinkSync(tempFile);
322
+ }
323
+ }
324
+ }
325
+
326
+ async function printHtmlLabel(htmlPath: string, options: PrintOptions): Promise<void> {
327
+ const tempDir = os.tmpdir();
328
+ const tempFile = path.join(tempDir, `label-html-${Date.now()}.png`);
329
+
330
+ try {
331
+ // CSS mm units use 96 DPI. Convert tape mm to CSS pixels, then scale up.
332
+ // For 12mm tape: 12mm * 3.78 px/mm = ~45px CSS height
333
+ // deviceScaleFactor of 300/96 ≈ 3.125 gives us 300 DPI output
334
+ const cssPixelsPerMm = 96 / 25.4; // ~3.78
335
+ const viewportHeight = Math.round(options.tapeMm * cssPixelsPerMm);
336
+ const viewportWidth = Math.round(viewportHeight * 5); // ~5:1 aspect for labels
337
+ const scaleFactor = PRINT_DPI / 96; // Scale CSS pixels to print DPI
338
+
339
+ const renderedBuffer = await renderHtmlFile(htmlPath, {
340
+ width: viewportWidth,
341
+ height: viewportHeight,
342
+ deviceScaleFactor: scaleFactor,
343
+ keepScale: true, // Keep full resolution for printing
344
+ });
345
+
346
+ // Add margins for printer feed (3mm left, 4mm right)
347
+ const pxPerMm = PRINT_DPI / 25.4;
348
+ const leftMargin = Math.round(3 * pxPerMm);
349
+ const rightMargin = Math.round(4 * pxPerMm);
350
+
351
+ const rendered = await Jimp.read(renderedBuffer);
352
+ const paddedWidth = rendered.width + leftMargin + rightMargin;
353
+ const padded = new Jimp({ width: paddedWidth, height: rendered.height, color: 0xffffffff });
354
+ padded.composite(rendered, leftMargin, 0);
355
+
356
+ const imageBuffer = await padded.getBuffer("image/png");
357
+ fs.writeFileSync(tempFile, imageBuffer);
358
+
359
+ await printImage(tempFile, options.printer, options.tapeMm);
360
+ } finally {
361
+ if (fs.existsSync(tempFile)) {
362
+ fs.unlinkSync(tempFile);
363
+ }
364
+ await closeBrowser();
365
+ }
366
+ }
367
+
368
+ async function listPrinters(): Promise<void> {
369
+ return new Promise((resolve, reject) => {
370
+ exec('powershell -Command "Get-Printer | Where-Object { $_.Name -like \'*Brother*\' -or $_.Name -like \'*PT*\' } | Select-Object -ExpandProperty Name"',
371
+ (error, stdout, stderr) => {
372
+ if (error) {
373
+ reject(new Error(`Failed to list printers: ${stderr}`));
374
+ return;
375
+ }
376
+ const printers = stdout.trim().split("\n").filter(p => p.trim());
377
+ if (printers.length === 0) {
378
+ console.log("No Brother printers found");
379
+ } else {
380
+ console.log("Brother printers:");
381
+ printers.forEach(p => console.log(` ${p.trim()}`));
382
+ }
383
+ resolve();
384
+ });
385
+ });
386
+ }
387
+
388
+ async function detectTapeSize(printerName: string): Promise<number | null> {
389
+ // First check config file for user-specified tape size
390
+ const config = loadConfig();
391
+ if (config.defaultTape) {
392
+ const mm = parseInt(config.defaultTape.replace("mm", ""), 10);
393
+ if (!isNaN(mm) && TAPE_SIZES[`${mm}mm`]) {
394
+ return mm;
395
+ }
396
+ }
397
+
398
+ // Fall back to WMI query (may be stale after tape change)
399
+ return new Promise((resolve) => {
400
+ exec(`powershell -Command "(Get-WmiObject Win32_PrinterConfiguration -Filter \\"Name='${printerName}'\\").PaperWidth"`,
401
+ (error, stdout) => {
402
+ if (error || !stdout.trim()) {
403
+ resolve(null);
404
+ return;
405
+ }
406
+ // PaperWidth is in tenths of mm
407
+ const tenthsMm = parseInt(stdout.trim(), 10);
408
+ if (isNaN(tenthsMm)) {
409
+ resolve(null);
410
+ return;
411
+ }
412
+ const mm = Math.round(tenthsMm / 10);
413
+ resolve(mm);
414
+ });
415
+ });
416
+ }
417
+
418
+ // Default printer name
419
+ const DEFAULT_PRINTER = "Brother PT-P710BT";
420
+
421
+ // Label sizes for P-touch tapes at 300 DPI (high quality)
422
+ // PT-P710BT has 180 DPI print head; 300 DPI source ensures max quality
423
+ // Printable area from printer: 0.71" height for 24mm tape, proportional for others
424
+ const PRINT_DPI = 300;
425
+ const TAPE_SIZES: Record<string, { width: number; height: number; mm: number }> = {
426
+ "6mm": { width: 1137, height: 56, mm: 6 }, // 3.79" x 0.19" @ 300 DPI
427
+ "9mm": { width: 1137, height: 84, mm: 9 }, // 3.79" x 0.28" @ 300 DPI
428
+ "12mm": { width: 1137, height: 113, mm: 12 }, // 3.79" x 0.38" @ 300 DPI
429
+ "18mm": { width: 1137, height: 169, mm: 18 }, // 3.79" x 0.56" @ 300 DPI
430
+ "24mm": { width: 1137, height: 213, mm: 24 }, // 3.79" x 0.71" @ 300 DPI
431
+ };
432
+
433
+ program
434
+ .name("brother-print")
435
+ .description("Print labels to Brother P-touch and QL label printers")
436
+ .version("1.0.0");
437
+
438
+ program
439
+ .command("print <text>")
440
+ .description("Print a text label (use \\\\n for newlines)")
441
+ .option("-p, --printer <name>", "Printer name", DEFAULT_PRINTER)
442
+ .option("-t, --tape <size>", "Tape size: 6mm, 9mm, 12mm, 18mm, 24mm, or 'auto'", "auto")
443
+ .option("-w, --width <px>", "Custom width in pixels")
444
+ .option("-H, --img-height <px>", "Custom height in pixels")
445
+ .action(async (text: string, opts) => {
446
+ try {
447
+ let tapeMm: number;
448
+ let tapeSize;
449
+
450
+ if (opts.tape === "auto") {
451
+ const detected = await detectTapeSize(opts.printer);
452
+ if (detected) {
453
+ tapeMm = detected;
454
+ tapeSize = TAPE_SIZES[`${detected}mm`] || TAPE_SIZES["24mm"];
455
+ console.log(`Detected ${detected}mm tape`);
456
+ } else {
457
+ console.log("Could not detect tape, using 24mm");
458
+ tapeMm = 24;
459
+ tapeSize = TAPE_SIZES["24mm"];
460
+ }
461
+ } else {
462
+ tapeSize = TAPE_SIZES[opts.tape] || TAPE_SIZES["24mm"];
463
+ tapeMm = tapeSize.mm;
464
+ }
465
+
466
+ const options: PrintOptions = {
467
+ printer: opts.printer,
468
+ fontSize: 32,
469
+ width: opts.width ? parseInt(opts.width, 10) : tapeSize.width, // label length
470
+ height: opts.imgHeight ? parseInt(opts.imgHeight, 10) : tapeSize.height, // tape height (printable)
471
+ tapeMm: tapeMm,
472
+ };
473
+
474
+ await printLabel(text, options);
475
+ console.log("Label printed successfully");
476
+ } catch (err) {
477
+ console.error(`Error: ${(err as Error).message}`);
478
+ process.exit(1);
479
+ }
480
+ });
481
+
482
+ program
483
+ .command("list")
484
+ .description("List connected Brother printers")
485
+ .action(async () => {
486
+ try {
487
+ await listPrinters();
488
+ } catch (err) {
489
+ console.error(`Error: ${(err as Error).message}`);
490
+ process.exit(1);
491
+ }
492
+ });
493
+
494
+ program
495
+ .command("image <file>")
496
+ .description("Print an image file (PNG, JPG, BMP)")
497
+ .option("-p, --printer <name>", "Printer name", DEFAULT_PRINTER)
498
+ .option("-t, --tape <size>", "Tape size: 6mm, 9mm, 12mm, 18mm, 24mm, or 'auto'", "auto")
499
+ .action(async (file: string, opts) => {
500
+ try {
501
+ let tapeMm: number;
502
+ let tapeSize;
503
+
504
+ if (opts.tape === "auto") {
505
+ const detected = await detectTapeSize(opts.printer);
506
+ if (detected) {
507
+ tapeMm = detected;
508
+ tapeSize = TAPE_SIZES[`${detected}mm`] || TAPE_SIZES["24mm"];
509
+ console.log(`Detected ${detected}mm tape`);
510
+ } else {
511
+ console.log("Could not detect tape, using 24mm");
512
+ tapeMm = 24;
513
+ tapeSize = TAPE_SIZES["24mm"];
514
+ }
515
+ } else {
516
+ tapeSize = TAPE_SIZES[opts.tape] || TAPE_SIZES["24mm"];
517
+ tapeMm = tapeSize.mm;
518
+ }
519
+
520
+ const absolutePath = path.resolve(file);
521
+ await printImage(absolutePath, opts.printer, tapeMm);
522
+ console.log("Image printed successfully");
523
+ } catch (err) {
524
+ console.error(`Error: ${(err as Error).message}`);
525
+ process.exit(1);
526
+ }
527
+ });
528
+
529
+ program
530
+ .command("html <file>")
531
+ .description("Print an HTML file as a label")
532
+ .option("-p, --printer <name>", "Printer name", DEFAULT_PRINTER)
533
+ .option("-t, --tape <size>", "Tape size: 6mm, 9mm, 12mm, 18mm, 24mm, or 'auto'", "auto")
534
+ .action(async (file: string, opts) => {
535
+ try {
536
+ let tapeMm: number;
537
+ let tapeSize;
538
+
539
+ if (opts.tape === "auto") {
540
+ const detected = await detectTapeSize(opts.printer);
541
+ if (detected) {
542
+ tapeMm = detected;
543
+ tapeSize = TAPE_SIZES[`${detected}mm`] || TAPE_SIZES["24mm"];
544
+ console.log(`Detected ${detected}mm tape`);
545
+ } else {
546
+ console.log("Could not detect tape, using 24mm");
547
+ tapeMm = 24;
548
+ tapeSize = TAPE_SIZES["24mm"];
549
+ }
550
+ } else {
551
+ tapeSize = TAPE_SIZES[opts.tape] || TAPE_SIZES["24mm"];
552
+ tapeMm = tapeSize.mm;
553
+ }
554
+
555
+ const options: PrintOptions = {
556
+ printer: opts.printer,
557
+ fontSize: 32,
558
+ width: tapeSize.width, // label length
559
+ height: tapeSize.height, // tape height (printable)
560
+ tapeMm: tapeMm,
561
+ };
562
+
563
+ await printHtmlLabel(file, options);
564
+ console.log("HTML label printed successfully");
565
+ } catch (err) {
566
+ console.error(`Error: ${(err as Error).message}`);
567
+ process.exit(1);
568
+ }
569
+ });
570
+
571
+ program
572
+ .command("preview <text>")
573
+ .description("Save label as PNG without printing")
574
+ .option("-f, --font-size <size>", "Font size (16 or 32)", "32")
575
+ .option("-t, --tape <size>", "Tape size: 6mm, 9mm, 12mm, 18mm, 24mm", "12mm")
576
+ .option("-w, --width <px>", "Custom width in pixels")
577
+ .option("-H, --img-height <px>", "Custom height in pixels")
578
+ .option("-o, --output <file>", "Output file path", "label-preview.png")
579
+ .action(async (text: string, opts) => {
580
+ try {
581
+ const tapeSize = TAPE_SIZES[opts.tape] || TAPE_SIZES["12mm"];
582
+ const options: PrintOptions = {
583
+ printer: "",
584
+ fontSize: parseInt(opts.fontSize, 10),
585
+ width: opts.width ? parseInt(opts.width, 10) : tapeSize.width, // label length
586
+ height: opts.imgHeight ? parseInt(opts.imgHeight, 10) : tapeSize.height, // tape height
587
+ tapeMm: tapeSize.mm,
588
+ };
589
+
590
+ const imageBuffer = await createLabelImage(text, options);
591
+ fs.writeFileSync(opts.output, imageBuffer);
592
+ console.log(`Preview saved to ${opts.output}`);
593
+ } catch (err) {
594
+ console.error(`Error: ${(err as Error).message}`);
595
+ process.exit(1);
596
+ }
597
+ });
598
+
599
+ program
600
+ .command("qr <data>")
601
+ .description("Print a QR code label")
602
+ .option("-p, --printer <name>", "Printer name", DEFAULT_PRINTER)
603
+ .option("-t, --tape <size>", "Tape size: 6mm, 9mm, 12mm, 18mm, 24mm, or 'auto'", "auto")
604
+ .option("-l, --label <text>", "Optional text label beside QR code")
605
+ .option("-o, --output <file>", "Save to file instead of printing")
606
+ .action(async (data: string, opts) => {
607
+ try {
608
+ let tapeMm: number;
609
+ let tapeSize;
610
+
611
+ if (opts.tape === "auto") {
612
+ const detected = await detectTapeSize(opts.printer);
613
+ if (detected) {
614
+ tapeMm = detected;
615
+ tapeSize = TAPE_SIZES[`${detected}mm`] || TAPE_SIZES["24mm"];
616
+ console.log(`Detected ${detected}mm tape`);
617
+ } else {
618
+ console.log("Could not detect tape, using 24mm");
619
+ tapeMm = 24;
620
+ tapeSize = TAPE_SIZES["24mm"];
621
+ }
622
+ } else {
623
+ tapeSize = TAPE_SIZES[opts.tape] || TAPE_SIZES["24mm"];
624
+ tapeMm = tapeSize.mm;
625
+ }
626
+
627
+ const options: PrintOptions = {
628
+ printer: opts.printer,
629
+ fontSize: 32,
630
+ width: tapeSize.width,
631
+ height: tapeSize.height,
632
+ tapeMm: tapeMm,
633
+ };
634
+
635
+ const imageBuffer = await createQrLabelImage(data, options, opts.label);
636
+
637
+ if (opts.output) {
638
+ fs.writeFileSync(opts.output, imageBuffer);
639
+ console.log(`QR label saved to ${opts.output}`);
640
+ } else {
641
+ const tempDir = os.tmpdir();
642
+ const tempFile = path.join(tempDir, `qr-label-${Date.now()}.png`);
643
+ try {
644
+ fs.writeFileSync(tempFile, imageBuffer);
645
+ await printImage(tempFile, options.printer, options.tapeMm);
646
+ console.log("QR label printed successfully");
647
+ } finally {
648
+ if (fs.existsSync(tempFile)) {
649
+ fs.unlinkSync(tempFile);
650
+ }
651
+ }
652
+ }
653
+ } catch (err) {
654
+ console.error(`Error: ${(err as Error).message}`);
655
+ process.exit(1);
656
+ }
657
+ });
658
+
659
+ program
660
+ .command("config")
661
+ .description("Set default tape size and printer (stored in ~/.brother-label.json)")
662
+ .option("-t, --tape <size>", "Set default tape size: 6mm, 9mm, 12mm, 18mm, 24mm")
663
+ .option("-p, --printer <name>", "Set default printer name")
664
+ .option("-s, --show", "Show current configuration")
665
+ .action((opts) => {
666
+ const config = loadConfig();
667
+
668
+ if (opts.show || (!opts.tape && !opts.printer)) {
669
+ console.log("Current configuration:");
670
+ console.log(` Config file: ${CONFIG_PATH}`);
671
+ console.log(` Default tape: ${config.defaultTape || "(not set - will use WMI detection)"}`);
672
+ console.log(` Default printer: ${config.defaultPrinter || DEFAULT_PRINTER}`);
673
+ console.log("\nAvailable tape sizes: 6mm, 9mm, 12mm, 18mm, 24mm");
674
+ return;
675
+ }
676
+
677
+ if (opts.tape) {
678
+ const tape = opts.tape.endsWith("mm") ? opts.tape : `${opts.tape}mm`;
679
+ if (!TAPE_SIZES[tape]) {
680
+ console.error(`Invalid tape size: ${opts.tape}`);
681
+ console.error("Valid sizes: 6mm, 9mm, 12mm, 18mm, 24mm");
682
+ process.exit(1);
683
+ }
684
+ config.defaultTape = tape;
685
+ console.log(`Default tape set to: ${tape}`);
686
+ }
687
+
688
+ if (opts.printer) {
689
+ config.defaultPrinter = opts.printer;
690
+ console.log(`Default printer set to: ${opts.printer}`);
691
+ }
692
+
693
+ saveConfig(config);
694
+ console.log(`Configuration saved to ${CONFIG_PATH}`);
695
+ });
696
+
697
+ program.parse();