@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.
package/api.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Brother Label Printer API
3
+ * Programmatic interface for printing labels on Brother P-touch printers
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import * as os from "os";
9
+ import { renderHtmlFile, renderHtmlString, closeBrowser } from "./render.js";
10
+ import { Jimp, loadFont, measureText, measureTextHeight } from "jimp";
11
+ import { spawn } from "child_process";
12
+ import QRCode from "qrcode";
13
+
14
+ // Types
15
+ export type TapeSize = 6 | 9 | 12 | 18 | 24;
16
+ export type Orientation = "landscape" | "portrait";
17
+
18
+ export interface PrinterConfig {
19
+ defaultTape?: TapeSize;
20
+ defaultPrinter?: string;
21
+ }
22
+
23
+ export interface PrinterInfo {
24
+ name: string;
25
+ }
26
+
27
+ export interface PrintOptions {
28
+ // Content (exactly one required)
29
+ text?: string;
30
+ html?: string;
31
+ htmlPath?: string;
32
+ textFile?: string;
33
+ imagePath?: string;
34
+ imageBuffer?: Buffer;
35
+ qr?: string; // QR code data to encode
36
+
37
+ // Settings
38
+ tape?: TapeSize;
39
+ printer?: string;
40
+ orientation?: Orientation;
41
+ length?: number; // explicit length in mm
42
+ basePath?: string; // base path for inline HTML resources
43
+ aspect?: string; // width:height ratio for HTML (e.g., "3.5:2")
44
+ qrLabel?: string; // optional text label beside QR code
45
+ }
46
+
47
+ export interface PrintResult {
48
+ image: Buffer;
49
+ }
50
+
51
+ // Constants
52
+ const CONFIG_PATH = path.join(os.homedir(), ".brother-label.json");
53
+ const DEFAULT_PRINTER = "Brother PT-P710BT";
54
+ const PRINT_DPI = 300; // High resolution for quality output
55
+
56
+ // Tape sizes: height is printable area in pixels at 300 DPI
57
+ const TAPE_SIZES: Record<TapeSize, { width: number; height: number }> = {
58
+ 6: { width: 1137, height: 56 }, // 3.79" x 0.19" @ 300 DPI
59
+ 9: { width: 1137, height: 84 }, // 3.79" x 0.28" @ 300 DPI
60
+ 12: { width: 1137, height: 113 }, // 3.79" x 0.38" @ 300 DPI
61
+ 18: { width: 1137, height: 169 }, // 3.79" x 0.56" @ 300 DPI
62
+ 24: { width: 1137, height: 213 }, // 3.79" x 0.71" @ 300 DPI
63
+ };
64
+
65
+ // Brother CustomMediaSize names and widths in microns
66
+ const MEDIA_OPTIONS: Record<TapeSize, { name: string; width: number }> = {
67
+ 6: { name: "CustomMediaSize257", width: 5900 },
68
+ 9: { name: "CustomMediaSize258", width: 9000 },
69
+ 12: { name: "CustomMediaSize259", width: 11900 },
70
+ 18: { name: "CustomMediaSize260", width: 18100 },
71
+ 24: { name: "CustomMediaSize261", width: 24000 },
72
+ };
73
+
74
+ // Config functions
75
+ export function getConfig(): PrinterConfig {
76
+ try {
77
+ if (fs.existsSync(CONFIG_PATH)) {
78
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
79
+ // Handle legacy format where tape was stored as "24mm" string
80
+ if (typeof raw.defaultTape === "string") {
81
+ raw.defaultTape = parseInt(raw.defaultTape.replace("mm", ""), 10) as TapeSize;
82
+ }
83
+ return raw;
84
+ }
85
+ } catch {
86
+ // Ignore config errors
87
+ }
88
+ return {};
89
+ }
90
+
91
+ export function setConfig(config: Partial<PrinterConfig>): void {
92
+ const current = getConfig();
93
+ const merged = { ...current, ...config };
94
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2));
95
+ }
96
+
97
+ export function getConfigPath(): string {
98
+ return CONFIG_PATH;
99
+ }
100
+
101
+ // Printer listing
102
+ export async function listPrinters(): Promise<PrinterInfo[]> {
103
+ return new Promise((resolve, reject) => {
104
+ const ps = spawn("powershell", [
105
+ "-Command",
106
+ "Get-Printer | Where-Object { $_.Name -like '*Brother*' -or $_.Name -like '*PT*' -or $_.Name -like '*QL*' } | Select-Object -ExpandProperty Name"
107
+ ], { stdio: "pipe" });
108
+
109
+ let stdout = "";
110
+ let stderr = "";
111
+ ps.stdout.on("data", (data) => { stdout += data.toString(); });
112
+ ps.stderr.on("data", (data) => { stderr += data.toString(); });
113
+
114
+ ps.on("close", (code) => {
115
+ if (code !== 0) {
116
+ reject(new Error(`Failed to list printers: ${stderr}`));
117
+ return;
118
+ }
119
+ const printers = stdout.trim().split("\n")
120
+ .filter(p => p.trim())
121
+ .map(name => ({ name: name.trim() }));
122
+ resolve(printers);
123
+ });
124
+ });
125
+ }
126
+
127
+ // Calculate HTML viewport dimensions from tape height and optional aspect ratio
128
+ function getHtmlDimensions(tapeHeight: number, aspect?: string): { width: number; height: number } {
129
+ if (!aspect) {
130
+ // Default: square viewport
131
+ return { width: tapeHeight, height: tapeHeight };
132
+ }
133
+
134
+ // Parse aspect ratio "width:height" or "width/height" (e.g., "3.5:2" or "3.5/2")
135
+ const separator = aspect.includes("/") ? "/" : ":";
136
+ const parts = aspect.split(separator);
137
+ if (parts.length !== 2) {
138
+ throw new Error(`Invalid aspect ratio: ${aspect}. Use format "width:height" or "width/height"`);
139
+ }
140
+ const aspectWidth = parseFloat(parts[0]);
141
+ const aspectHeight = parseFloat(parts[1]);
142
+ if (isNaN(aspectWidth) || isNaN(aspectHeight) || aspectHeight === 0) {
143
+ throw new Error(`Invalid aspect ratio: ${aspect}. Use format "width:height" (e.g., "3.5:2")`);
144
+ }
145
+
146
+ // Scale so height fits tape, width is proportional
147
+ const height = tapeHeight;
148
+ const width = Math.round(tapeHeight * (aspectWidth / aspectHeight));
149
+ return { width, height };
150
+ }
151
+
152
+ // Validation
153
+ function validateOptions(options: PrintOptions): void {
154
+ const contentFields = [
155
+ options.text,
156
+ options.html,
157
+ options.htmlPath,
158
+ options.textFile,
159
+ options.imagePath,
160
+ options.imageBuffer,
161
+ options.qr,
162
+ ].filter(f => f !== undefined);
163
+
164
+ if (contentFields.length === 0) {
165
+ throw new Error("No content provided. Specify one of: text, html, htmlPath, textFile, imagePath, imageBuffer, qr");
166
+ }
167
+ if (contentFields.length > 1) {
168
+ throw new Error("Multiple content options provided. Specify exactly one.");
169
+ }
170
+
171
+ if (options.tape !== undefined && !TAPE_SIZES[options.tape]) {
172
+ throw new Error(`Invalid tape size: ${options.tape}. Valid sizes: 6, 9, 12, 18, 24`);
173
+ }
174
+ }
175
+
176
+ // Resolve effective settings from options + config + defaults
177
+ function resolveSettings(options: PrintOptions): { tape: TapeSize; printer: string } {
178
+ const config = getConfig();
179
+ return {
180
+ tape: options.tape ?? config.defaultTape ?? 24,
181
+ printer: options.printer ?? config.defaultPrinter ?? DEFAULT_PRINTER,
182
+ };
183
+ }
184
+
185
+ // Render text to image buffer
186
+ async function renderText(text: string, tape: TapeSize): Promise<Buffer> {
187
+ const tapeSize = TAPE_SIZES[tape];
188
+ const targetHeight = tapeSize.height;
189
+ const lines = text.split("\\n").join("\n");
190
+
191
+ // Load largest font for quality
192
+ const fontDir = path.join(path.dirname(new URL(import.meta.url).pathname).replace(/^\/([A-Z]:)/, "$1"), "node_modules/@jimp/plugin-print/fonts/open-sans");
193
+ const fontPath = path.join(fontDir, "open-sans-128-black", "open-sans-128-black.fnt");
194
+ const font = await loadFont(fontPath);
195
+
196
+ // Measure text
197
+ const textWidth = measureText(font, lines);
198
+ const textHeight = measureTextHeight(font, lines, textWidth + 100);
199
+
200
+ // Create temp image for text
201
+ const padding = 10;
202
+ const imgWidth = textWidth + padding * 2;
203
+ const imgHeight = textHeight + padding * 2;
204
+
205
+ const tempImage = new Jimp({ width: imgWidth, height: imgHeight, color: 0xffffffff });
206
+ tempImage.print({ font, x: padding, y: padding, text: lines });
207
+
208
+ // Scale to fit tape height
209
+ const scale = targetHeight / imgHeight;
210
+ const finalWidth = Math.round(imgWidth * scale);
211
+ const finalHeight = Math.round(imgHeight * scale);
212
+ tempImage.resize({ w: finalWidth, h: finalHeight });
213
+
214
+ // Create final image with padding
215
+ const hPadding = Math.round(targetHeight * 0.2);
216
+ const outputWidth = finalWidth + hPadding * 2;
217
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
218
+
219
+ const xOffset = hPadding;
220
+ const yOffset = Math.round((targetHeight - finalHeight) / 2);
221
+ image.composite(tempImage, xOffset, yOffset);
222
+
223
+ return image.getBuffer("image/png");
224
+ }
225
+
226
+ // Render QR code to image buffer
227
+ async function renderQr(data: string, tape: TapeSize, labelText?: string): Promise<Buffer> {
228
+ const tapeSize = TAPE_SIZES[tape];
229
+ const targetHeight = tapeSize.height;
230
+ const qrSize = Math.floor(targetHeight * 0.95);
231
+
232
+ // Margins in pixels at PRINT_DPI (3mm left, 4mm right)
233
+ const pxPerMm = PRINT_DPI / 25.4;
234
+ const leftMargin = Math.round(3 * pxPerMm);
235
+ const rightMargin = Math.round(4 * pxPerMm);
236
+
237
+ // Generate QR code at high resolution
238
+ const qrBuffer = await QRCode.toBuffer(data, {
239
+ type: "png",
240
+ width: qrSize,
241
+ margin: 0,
242
+ errorCorrectionLevel: "M",
243
+ color: { dark: "#000000", light: "#ffffff" },
244
+ });
245
+
246
+ const qrImage = await Jimp.read(qrBuffer);
247
+
248
+ if (!labelText) {
249
+ // QR only
250
+ const outputWidth = leftMargin + qrSize + rightMargin;
251
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
252
+ const yOffset = Math.floor((targetHeight - qrSize) / 2);
253
+ image.composite(qrImage, leftMargin, yOffset);
254
+ return image.getBuffer("image/png");
255
+ }
256
+
257
+ // QR + text label
258
+ const fontDir = path.join(path.dirname(new URL(import.meta.url).pathname).replace(/^\/([A-Z]:)/, "$1"), "node_modules/@jimp/plugin-print/fonts/open-sans");
259
+ const fontPath = path.join(fontDir, "open-sans-64-black", "open-sans-64-black.fnt");
260
+ const font = await loadFont(fontPath);
261
+
262
+ const textWidth = measureText(font, labelText);
263
+ const textHeight = measureTextHeight(font, labelText, textWidth + 50);
264
+
265
+ // Scale text to fit beside QR
266
+ const maxTextHeight = targetHeight * 0.8;
267
+ const textScale = Math.min(1, maxTextHeight / textHeight);
268
+ const scaledTextWidth = Math.round(textWidth * textScale);
269
+ const scaledTextHeight = Math.round(textHeight * textScale);
270
+
271
+ // Create text image
272
+ const textImg = new Jimp({ width: textWidth + 20, height: textHeight + 20, color: 0xffffffff });
273
+ textImg.print({ font, x: 10, y: 10, text: labelText });
274
+ if (textScale < 1) {
275
+ textImg.resize({ w: scaledTextWidth, h: scaledTextHeight });
276
+ }
277
+
278
+ // Compose: QR on left, text on right
279
+ const gap = Math.floor(targetHeight * 0.15);
280
+ const outputWidth = leftMargin + qrSize + gap + scaledTextWidth + rightMargin;
281
+ const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
282
+
283
+ const qrY = Math.floor((targetHeight - qrSize) / 2);
284
+ image.composite(qrImage, leftMargin, qrY);
285
+
286
+ const textY = Math.floor((targetHeight - scaledTextHeight) / 2);
287
+ image.composite(textImg, leftMargin + qrSize + gap, textY);
288
+
289
+ return image.getBuffer("image/png");
290
+ }
291
+
292
+ // Render content to image buffer
293
+ async function renderContent(options: PrintOptions, tape: TapeSize): Promise<Buffer> {
294
+ const tapeSize = TAPE_SIZES[tape];
295
+
296
+ if (options.text !== undefined) {
297
+ return renderText(options.text, tape);
298
+ }
299
+
300
+ if (options.textFile !== undefined) {
301
+ const text = fs.readFileSync(options.textFile, "utf-8");
302
+ return renderText(text, tape);
303
+ }
304
+
305
+ if (options.html !== undefined) {
306
+ const { width, height } = getHtmlDimensions(tapeSize.height, options.aspect);
307
+ const buffer = await renderHtmlString(options.html, { width, height }, options.basePath);
308
+ await closeBrowser();
309
+ return buffer;
310
+ }
311
+
312
+ if (options.htmlPath !== undefined) {
313
+ const { width, height } = getHtmlDimensions(tapeSize.height, options.aspect);
314
+ const buffer = await renderHtmlFile(options.htmlPath, { width, height });
315
+ await closeBrowser();
316
+ return buffer;
317
+ }
318
+
319
+ if (options.imagePath !== undefined) {
320
+ return fs.readFileSync(options.imagePath);
321
+ }
322
+
323
+ if (options.imageBuffer !== undefined) {
324
+ return options.imageBuffer;
325
+ }
326
+
327
+ if (options.qr !== undefined) {
328
+ return renderQr(options.qr, tape, options.qrLabel);
329
+ }
330
+
331
+ throw new Error("No content to render");
332
+ }
333
+
334
+ // Print single image buffer to printer
335
+ async function printBuffer(imageBuffer: Buffer, printer: string, tape: TapeSize): Promise<void> {
336
+ const media = MEDIA_OPTIONS[tape];
337
+
338
+ const tempPath = path.join(os.tmpdir(), `label-${Date.now()}.png`);
339
+ fs.writeFileSync(tempPath, imageBuffer);
340
+
341
+ try {
342
+ await new Promise<void>((resolve, reject) => {
343
+ const psScript = `
344
+ Add-Type -AssemblyName System.Drawing
345
+ Add-Type -AssemblyName System.Printing
346
+ Add-Type -AssemblyName ReachFramework
347
+ Add-Type -AssemblyName PresentationCore
348
+
349
+ $img = [System.Drawing.Image]::FromFile('${tempPath.replace(/\\/g, "\\\\")}')
350
+
351
+ $DPI = ${PRINT_DPI}
352
+ $MICRONS_PER_INCH = 25400
353
+
354
+ $labelLengthMicrons = [int]($img.Width / $DPI * $MICRONS_PER_INCH) + 1000
355
+ $tapeWidthMicrons = ${media.width}
356
+
357
+ $imgWidthWpf = $img.Width / $DPI * 96
358
+ $imgHeightWpf = $img.Height / $DPI * 96
359
+
360
+ $ticketXml = @"
361
+ <?xml version="1.0" encoding="UTF-8"?>
362
+ <psf:PrintTicket xmlns:psf="http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework"
363
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1"
364
+ xmlns:psk="http://schemas.microsoft.com/windows/2003/08/printing/printschemakeywords"
365
+ xmlns:ns0001="http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords">
366
+ <psf:Feature name="psk:PageMediaSize">
367
+ <psf:Option name="ns0001:${media.name}">
368
+ <psf:ScoredProperty name="psk:MediaSizeWidth">
369
+ <psf:Value xsi:type="xsd:integer">${media.width}</psf:Value>
370
+ </psf:ScoredProperty>
371
+ <psf:ScoredProperty name="psk:MediaSizeHeight">
372
+ <psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight" />
373
+ </psf:ScoredProperty>
374
+ </psf:Option>
375
+ </psf:Feature>
376
+ <psf:ParameterInit name="psk:PageMediaSizeMediaSizeHeight">
377
+ <psf:Value xsi:type="xsd:integer">$labelLengthMicrons</psf:Value>
378
+ </psf:ParameterInit>
379
+ <psf:Feature name="psk:PageOrientation">
380
+ <psf:Option name="psk:Landscape" />
381
+ </psf:Feature>
382
+ <psf:Feature name="ns0001:PageRollFeedToEndOfSheet">
383
+ <psf:Option name="ns0001:FeedToImageEdge" />
384
+ </psf:Feature>
385
+ <psf:Feature name="psk:PageMediaType">
386
+ <psf:Option name="psk:Label" />
387
+ </psf:Feature>
388
+ <psf:Feature name="psk:JobRollCutAtEndOfJob">
389
+ <psf:Option name="psk:CutSheetAtStandardMediaSize" />
390
+ </psf:Feature>
391
+ <psf:Feature name="ns0001:JobRollCutSheet">
392
+ <psf:Option name="ns0001:CutSheet">
393
+ <psf:ScoredProperty name="ns0001:CutSheetCount">
394
+ <psf:ParameterRef name="ns0001:JobCutSheetCount" />
395
+ </psf:ScoredProperty>
396
+ </psf:Option>
397
+ </psf:Feature>
398
+ <psf:ParameterInit name="ns0001:JobCutSheetCount">
399
+ <psf:Value xsi:type="xsd:integer">1</psf:Value>
400
+ </psf:ParameterInit>
401
+ </psf:PrintTicket>
402
+ "@
403
+
404
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ticketXml)
405
+ $memStream = New-Object System.IO.MemoryStream(,$xmlBytes)
406
+ $ticket = New-Object System.Printing.PrintTicket($memStream)
407
+
408
+ $server = New-Object System.Printing.LocalPrintServer
409
+ $queue = $server.GetPrintQueue('${printer}')
410
+
411
+ $xpsPath = [System.IO.Path]::GetTempFileName() + ".xps"
412
+
413
+ $pageWidth = $labelLengthMicrons / $MICRONS_PER_INCH * 96
414
+ $pageHeight = $tapeWidthMicrons / $MICRONS_PER_INCH * 96
415
+
416
+ $package = [System.IO.Packaging.Package]::Open($xpsPath, [System.IO.FileMode]::Create)
417
+ $xpsDoc = New-Object System.Windows.Xps.Packaging.XpsDocument($package)
418
+ $writer = [System.Windows.Xps.Packaging.XpsDocument]::CreateXpsDocumentWriter($xpsDoc)
419
+
420
+ $visual = New-Object System.Windows.Media.DrawingVisual
421
+ $dc = $visual.RenderOpen()
422
+
423
+ $bitmapImg = New-Object System.Windows.Media.Imaging.BitmapImage
424
+ $bitmapImg.BeginInit()
425
+ $bitmapImg.UriSource = New-Object System.Uri('${tempPath.replace(/\\/g, "\\\\")}')
426
+ $bitmapImg.EndInit()
427
+
428
+ $yOffset = ($pageHeight - $imgHeightWpf) / 2
429
+ $rect = New-Object System.Windows.Rect(0, $yOffset, $imgWidthWpf, $imgHeightWpf)
430
+ $dc.DrawImage($bitmapImg, $rect)
431
+ $dc.Close()
432
+
433
+ $writer.Write($visual, $ticket)
434
+
435
+ $xpsDoc.Close()
436
+ $package.Close()
437
+ $img.Dispose()
438
+
439
+ $xpsDocForPrint = New-Object System.Windows.Xps.Packaging.XpsDocument($xpsPath, [System.IO.FileAccess]::Read)
440
+ $seq = $xpsDocForPrint.GetFixedDocumentSequence()
441
+
442
+ $xpsWriter = [System.Printing.PrintQueue]::CreateXpsDocumentWriter($queue)
443
+ $xpsWriter.Write($seq, $ticket)
444
+
445
+ $xpsDocForPrint.Close()
446
+ Remove-Item $xpsPath -ErrorAction SilentlyContinue
447
+ `;
448
+
449
+ const ps = spawn("powershell", ["-Command", psScript], { stdio: "pipe" });
450
+
451
+ let stdout = "";
452
+ let stderr = "";
453
+ ps.stdout.on("data", (data) => { stdout += data.toString(); });
454
+ ps.stderr.on("data", (data) => { stderr += data.toString(); });
455
+
456
+ ps.on("close", (code) => {
457
+ if (code === 0) {
458
+ resolve();
459
+ } else {
460
+ reject(new Error(`Print failed: ${stderr}`));
461
+ }
462
+ });
463
+
464
+ ps.on("error", (err) => {
465
+ reject(new Error(`Failed to print: ${err.message}`));
466
+ });
467
+ });
468
+ } finally {
469
+ if (fs.existsSync(tempPath)) {
470
+ fs.unlinkSync(tempPath);
471
+ }
472
+ }
473
+ }
474
+
475
+ // Main API functions
476
+ export async function render(options: PrintOptions): Promise<Buffer> {
477
+ validateOptions(options);
478
+ const { tape } = resolveSettings(options);
479
+ return renderContent(options, tape);
480
+ }
481
+
482
+ export async function print(options: PrintOptions): Promise<PrintResult> {
483
+ validateOptions(options);
484
+ const { tape, printer } = resolveSettings(options);
485
+ const image = await renderContent(options, tape);
486
+ await printBuffer(image, printer, tape);
487
+ return { image };
488
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=brother-print.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brother-print.d.ts","sourceRoot":"","sources":["brother-print.ts"],"names":[],"mappings":""}