@bobfrankston/brother-label 1.0.14 → 1.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.
package/api.ts CHANGED
@@ -1,615 +1,429 @@
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 { fileURLToPath } from "url";
9
- import * as os from "os";
10
- import { renderHtmlFile, renderHtmlString, closeBrowser } from "./render.js";
11
- import { Jimp, loadFont, measureText, measureTextHeight } from "jimp";
12
- import { spawn } from "child_process";
13
- import QRCode from "qrcode";
14
-
15
- // Types
16
- export type TapeSize = 6 | 9 | 12 | 18 | 24;
17
- export type Orientation = "landscape" | "portrait";
18
-
19
- export interface PrinterConfig {
20
- defaultTape?: TapeSize;
21
- defaultPrinter?: string;
22
- }
23
-
24
- export interface PrinterInfo {
25
- name: string;
26
- }
27
-
28
- export interface PrintOptions {
29
- // Content (exactly one required)
30
- text?: string;
31
- html?: string;
32
- htmlPath?: string;
33
- textFile?: string;
34
- imagePath?: string;
35
- imageBuffer?: Buffer;
36
- qr?: string; // QR code data to encode
37
-
38
- // Settings
39
- tape?: TapeSize;
40
- printer?: string;
41
- orientation?: Orientation;
42
- length?: number; // explicit length in mm
43
- basePath?: string; // base path for inline HTML resources
44
- aspect?: string; // width:height ratio for HTML (e.g., "3.5:2")
45
- qrLabel?: string; // optional text label beside QR code
46
- textHeight?: string; // text height: "12mm", ".5in", "50%" (of tape height)
47
- }
48
-
49
- export interface PrintResult {
50
- image: Buffer;
51
- }
52
-
53
- export interface Segment {
54
- type: "text" | "qr";
55
- value: string;
56
- }
57
-
58
- // Constants
59
- const CONFIG_PATH = path.join(os.homedir(), ".brother-label.json");
60
- const DEFAULT_PRINTER = "Brother PT-P710BT";
61
- const PRINT_DPI = 300; // High resolution for quality output
62
-
63
- // Tape sizes: height is printable area in pixels at 300 DPI
64
- const TAPE_SIZES: Record<TapeSize, { width: number; height: number }> = {
65
- 6: { width: 1137, height: 56 }, // 3.79" x 0.19" @ 300 DPI
66
- 9: { width: 1137, height: 84 }, // 3.79" x 0.28" @ 300 DPI
67
- 12: { width: 1137, height: 113 }, // 3.79" x 0.38" @ 300 DPI
68
- 18: { width: 1137, height: 169 }, // 3.79" x 0.56" @ 300 DPI
69
- 24: { width: 1137, height: 213 }, // 3.79" x 0.71" @ 300 DPI
70
- };
71
-
72
- // Brother CustomMediaSize names and widths in microns
73
- const MEDIA_OPTIONS: Record<TapeSize, { name: string; width: number }> = {
74
- 6: { name: "CustomMediaSize257", width: 5900 },
75
- 9: { name: "CustomMediaSize258", width: 9000 },
76
- 12: { name: "CustomMediaSize259", width: 11900 },
77
- 18: { name: "CustomMediaSize260", width: 18100 },
78
- 24: { name: "CustomMediaSize261", width: 24000 },
79
- };
80
-
81
- // Config functions
82
- export function getConfig(): PrinterConfig {
83
- try {
84
- if (fs.existsSync(CONFIG_PATH)) {
85
- const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
86
- // Handle legacy format where tape was stored as "24mm" string
87
- if (typeof raw.defaultTape === "string") {
88
- raw.defaultTape = parseInt(raw.defaultTape.replace("mm", ""), 10) as TapeSize;
89
- }
90
- return raw;
91
- }
92
- } catch {
93
- // Ignore config errors
94
- }
95
- return {};
96
- }
97
-
98
- export function setConfig(config: Partial<PrinterConfig>): void {
99
- const current = getConfig();
100
- const merged = { ...current, ...config };
101
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2));
102
- }
103
-
104
- export function getConfigPath(): string {
105
- return CONFIG_PATH;
106
- }
107
-
108
- // Printer listing
109
- export async function listPrinters(): Promise<PrinterInfo[]> {
110
- return new Promise((resolve, reject) => {
111
- const ps = spawn("powershell", [
112
- "-Command",
113
- "Get-Printer | Where-Object { $_.Name -like '*Brother*' -or $_.Name -like '*PT*' -or $_.Name -like '*QL*' } | Select-Object -ExpandProperty Name"
114
- ], { stdio: "pipe" });
115
-
116
- let stdout = "";
117
- let stderr = "";
118
- ps.stdout.on("data", (data) => { stdout += data.toString(); });
119
- ps.stderr.on("data", (data) => { stderr += data.toString(); });
120
-
121
- ps.on("close", (code) => {
122
- if (code !== 0) {
123
- reject(new Error(`Failed to list printers: ${stderr}`));
124
- return;
125
- }
126
- const printers = stdout.trim().split("\n")
127
- .filter(p => p.trim())
128
- .map(name => ({ name: name.trim() }));
129
- resolve(printers);
130
- });
131
- });
132
- }
133
-
134
- // Calculate HTML viewport dimensions from tape height and optional aspect ratio
135
- function getHtmlDimensions(tapeHeight: number, aspect?: string): { width?: number; height: number } {
136
- if (!aspect) {
137
- // Auto-detect width from content
138
- return { width: undefined, height: tapeHeight };
139
- }
140
-
141
- // Parse aspect ratio "width:height" or "width/height" (e.g., "3.5:2" or "3.5/2")
142
- const separator = aspect.includes("/") ? "/" : ":";
143
- const parts = aspect.split(separator);
144
- if (parts.length !== 2) {
145
- throw new Error(`Invalid aspect ratio: ${aspect}. Use format "width:height" or "width/height"`);
146
- }
147
- const aspectWidth = parseFloat(parts[0]);
148
- const aspectHeight = parseFloat(parts[1]);
149
- if (isNaN(aspectWidth) || isNaN(aspectHeight) || aspectHeight === 0) {
150
- throw new Error(`Invalid aspect ratio: ${aspect}. Use format "width:height" (e.g., "3.5:2")`);
151
- }
152
-
153
- // Scale so height fits tape, width is proportional
154
- const height = tapeHeight;
155
- const width = Math.round(tapeHeight * (aspectWidth / aspectHeight));
156
- return { width, height };
157
- }
158
-
159
- // Validation
160
- function validateOptions(options: PrintOptions): void {
161
- const contentFields = [
162
- options.text,
163
- options.html,
164
- options.htmlPath,
165
- options.textFile,
166
- options.imagePath,
167
- options.imageBuffer,
168
- options.qr,
169
- ].filter(f => f !== undefined);
170
-
171
- if (contentFields.length === 0) {
172
- throw new Error("No content provided. Specify one of: text, html, htmlPath, textFile, imagePath, imageBuffer, qr");
173
- }
174
- if (contentFields.length > 1) {
175
- throw new Error("Multiple content options provided. Specify exactly one.");
176
- }
177
-
178
- if (options.tape !== undefined && !TAPE_SIZES[options.tape]) {
179
- throw new Error(`Invalid tape size: ${options.tape}. Valid sizes: 6, 9, 12, 18, 24`);
180
- }
181
- }
182
-
183
- // Resolve effective settings from options + config + defaults
184
- function resolveSettings(options: PrintOptions): { tape: TapeSize; printer: string } {
185
- const config = getConfig();
186
- return {
187
- tape: options.tape ?? config.defaultTape ?? 24,
188
- printer: options.printer ?? config.defaultPrinter ?? DEFAULT_PRINTER,
189
- };
190
- }
191
-
192
- // Parse a size spec (12px, 1mm, .2in, 50%) to pixels
193
- function parseSize(spec: string, referencePx: number): number {
194
- const trimmed = spec.trim().toLowerCase();
195
- if (trimmed.endsWith("px")) {
196
- const px = parseFloat(trimmed.slice(0, -2));
197
- if (isNaN(px) || px < 0) throw new Error(`Invalid size: ${spec}`);
198
- return Math.round(px);
199
- }
200
- if (trimmed.endsWith("%")) {
201
- const pct = parseFloat(trimmed.slice(0, -1));
202
- if (isNaN(pct) || pct < 0) throw new Error(`Invalid size: ${spec}`);
203
- return Math.round(referencePx * pct / 100);
204
- }
205
- if (trimmed.endsWith("mm")) {
206
- const mm = parseFloat(trimmed.slice(0, -2));
207
- if (isNaN(mm) || mm < 0) throw new Error(`Invalid size: ${spec}`);
208
- return Math.round(mm * PRINT_DPI / 25.4);
209
- }
210
- if (trimmed.endsWith("in")) {
211
- const inches = parseFloat(trimmed.slice(0, -2));
212
- if (isNaN(inches) || inches < 0) throw new Error(`Invalid size: ${spec}`);
213
- return Math.round(inches * PRINT_DPI);
214
- }
215
- // Bare number pixels
216
- const px = parseFloat(trimmed);
217
- if (!isNaN(px) && px >= 0) return Math.round(px);
218
- throw new Error(`Invalid size: ${spec}. Use "12px", "1mm", ".2in", or "50%"`);
219
- }
220
-
221
- // Parse text height spec to pixels
222
- function parseTextHeight(spec: string, tapeHeightPx: number): number {
223
- const trimmed = spec.trim().toLowerCase();
224
- if (trimmed.endsWith("%")) {
225
- const pct = parseFloat(trimmed.slice(0, -1));
226
- if (isNaN(pct) || pct <= 0) throw new Error(`Invalid text height: ${spec}`);
227
- return Math.round(tapeHeightPx * pct / 100);
228
- }
229
- if (trimmed.endsWith("mm")) {
230
- const mm = parseFloat(trimmed.slice(0, -2));
231
- if (isNaN(mm) || mm <= 0) throw new Error(`Invalid text height: ${spec}`);
232
- return Math.round(mm * PRINT_DPI / 25.4);
233
- }
234
- if (trimmed.endsWith("in")) {
235
- const inches = parseFloat(trimmed.slice(0, -2));
236
- if (isNaN(inches) || inches <= 0) throw new Error(`Invalid text height: ${spec}`);
237
- return Math.round(inches * PRINT_DPI);
238
- }
239
- throw new Error(`Invalid text height: ${spec}. Use "12mm", ".5in", or "50%"`);
240
- }
241
-
242
- // Render text to image buffer
243
- async function renderText(text: string, tape: TapeSize, textHeightSpec?: string): Promise<Buffer> {
244
- const tapeSize = TAPE_SIZES[tape];
245
- const targetHeight = textHeightSpec
246
- ? Math.min(parseTextHeight(textHeightSpec, tapeSize.height), tapeSize.height)
247
- : tapeSize.height;
248
- const lines = text.split("\\n").join("\n");
249
-
250
- // Load largest font for quality
251
- const fontDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "node_modules/@jimp/plugin-print/fonts/open-sans");
252
- const fontPath = path.join(fontDir, "open-sans-128-black", "open-sans-128-black.fnt");
253
- const font = await loadFont(fontPath);
254
-
255
- // Measure text
256
- const textWidth = measureText(font, lines);
257
- const textHeight = measureTextHeight(font, lines, textWidth + 100);
258
-
259
- // Create temp image for text
260
- const padding = 10;
261
- const imgWidth = textWidth + padding * 2;
262
- const imgHeight = textHeight + padding * 2;
263
-
264
- const tempImage = new Jimp({ width: imgWidth, height: imgHeight, color: 0xffffffff });
265
- tempImage.print({ font, x: padding, y: padding, text: lines });
266
-
267
- // Scale to fit tape height
268
- const scale = targetHeight / imgHeight;
269
- const finalWidth = Math.round(imgWidth * scale);
270
- const finalHeight = Math.round(imgHeight * scale);
271
- tempImage.resize({ w: finalWidth, h: finalHeight });
272
-
273
- // Create final image with padding
274
- const canvasHeight = tapeSize.height;
275
- const hPadding = Math.round(canvasHeight * 0.2);
276
- const outputWidth = finalWidth + hPadding * 2;
277
- const image = new Jimp({ width: outputWidth, height: canvasHeight, color: 0xffffffff });
278
-
279
- const xOffset = hPadding;
280
- const yOffset = Math.round((canvasHeight - finalHeight) / 2);
281
- image.composite(tempImage, xOffset, yOffset);
282
-
283
- return image.getBuffer("image/png");
284
- }
285
-
286
- // Render QR code to image buffer
287
- async function renderQr(data: string, tape: TapeSize, labelText?: string): Promise<Buffer> {
288
- const tapeSize = TAPE_SIZES[tape];
289
- const targetHeight = tapeSize.height;
290
- const qrSize = Math.floor(targetHeight * 0.95);
291
-
292
- // Margins in pixels at PRINT_DPI (3mm left, 4mm right)
293
- const pxPerMm = PRINT_DPI / 25.4;
294
- const leftMargin = Math.round(3 * pxPerMm);
295
- const rightMargin = Math.round(4 * pxPerMm);
296
-
297
- // Generate QR code at high resolution
298
- const qrBuffer = await QRCode.toBuffer(data, {
299
- type: "png",
300
- width: qrSize,
301
- margin: 0,
302
- errorCorrectionLevel: "M",
303
- color: { dark: "#000000", light: "#ffffff" },
304
- });
305
-
306
- const qrImage = await Jimp.read(qrBuffer);
307
-
308
- if (!labelText) {
309
- // QR only
310
- const outputWidth = leftMargin + qrSize + rightMargin;
311
- const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
312
- const yOffset = Math.floor((targetHeight - qrSize) / 2);
313
- image.composite(qrImage, leftMargin, yOffset);
314
- return image.getBuffer("image/png");
315
- }
316
-
317
- // QR + text label
318
- const fontDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "node_modules/@jimp/plugin-print/fonts/open-sans");
319
- const fontPath = path.join(fontDir, "open-sans-64-black", "open-sans-64-black.fnt");
320
- const font = await loadFont(fontPath);
321
-
322
- const textWidth = measureText(font, labelText);
323
- const textHeight = measureTextHeight(font, labelText, textWidth + 50);
324
-
325
- // Scale text to fit beside QR
326
- const maxTextHeight = targetHeight * 0.8;
327
- const textScale = Math.min(1, maxTextHeight / textHeight);
328
- const scaledTextWidth = Math.round(textWidth * textScale);
329
- const scaledTextHeight = Math.round(textHeight * textScale);
330
-
331
- // Create text image
332
- const textImg = new Jimp({ width: textWidth + 20, height: textHeight + 20, color: 0xffffffff });
333
- textImg.print({ font, x: 10, y: 10, text: labelText });
334
- if (textScale < 1) {
335
- textImg.resize({ w: scaledTextWidth, h: scaledTextHeight });
336
- }
337
-
338
- // Compose: QR on left, text on right
339
- const gap = Math.floor(targetHeight * 0.15);
340
- const outputWidth = leftMargin + qrSize + gap + scaledTextWidth + rightMargin;
341
- const image = new Jimp({ width: outputWidth, height: targetHeight, color: 0xffffffff });
342
-
343
- const qrY = Math.floor((targetHeight - qrSize) / 2);
344
- image.composite(qrImage, leftMargin, qrY);
345
-
346
- const textY = Math.floor((targetHeight - scaledTextHeight) / 2);
347
- image.composite(textImg, leftMargin + qrSize + gap, textY);
348
-
349
- return image.getBuffer("image/png");
350
- }
351
-
352
- // Render content to image buffer
353
- async function renderContent(options: PrintOptions, tape: TapeSize): Promise<Buffer> {
354
- const tapeSize = TAPE_SIZES[tape];
355
-
356
- if (options.text !== undefined) {
357
- return renderText(options.text, tape, options.textHeight);
358
- }
359
-
360
- if (options.textFile !== undefined) {
361
- const text = fs.readFileSync(options.textFile, "utf-8");
362
- return renderText(text, tape, options.textHeight);
363
- }
364
-
365
- if (options.html !== undefined) {
366
- const { width, height } = getHtmlDimensions(tapeSize.height, options.aspect);
367
- const buffer = await renderHtmlString(options.html, { width, height, tapeMm: tape }, options.basePath);
368
- await closeBrowser();
369
- return buffer;
370
- }
371
-
372
- if (options.htmlPath !== undefined) {
373
- const { width, height } = getHtmlDimensions(tapeSize.height, options.aspect);
374
- const buffer = await renderHtmlFile(options.htmlPath, { width, height, tapeMm: tape });
375
- await closeBrowser();
376
- return buffer;
377
- }
378
-
379
- if (options.imagePath !== undefined) {
380
- return fs.readFileSync(options.imagePath);
381
- }
382
-
383
- if (options.imageBuffer !== undefined) {
384
- return options.imageBuffer;
385
- }
386
-
387
- if (options.qr !== undefined) {
388
- return renderQr(options.qr, tape, options.qrLabel);
389
- }
390
-
391
- throw new Error("No content to render");
392
- }
393
-
394
- // Print single image buffer to printer
395
- async function printBuffer(imageBuffer: Buffer, printer: string, tape: TapeSize): Promise<void> {
396
- const media = MEDIA_OPTIONS[tape];
397
-
398
- const tempPath = path.join(os.tmpdir(), `label-${Date.now()}.png`);
399
- fs.writeFileSync(tempPath, imageBuffer);
400
-
401
- try {
402
- await new Promise<void>((resolve, reject) => {
403
- const psScript = `
404
- Add-Type -AssemblyName System.Drawing
405
- Add-Type -AssemblyName System.Printing
406
- Add-Type -AssemblyName ReachFramework
407
- Add-Type -AssemblyName PresentationCore
408
-
409
- $img = [System.Drawing.Image]::FromFile('${tempPath.replace(/\\/g, "\\\\")}')
410
-
411
- $DPI = ${PRINT_DPI}
412
- $MICRONS_PER_INCH = 25400
413
-
414
- $labelLengthMicrons = [int]($img.Width / $DPI * $MICRONS_PER_INCH) + 3000 # +2mm offset +1mm buffer
415
- $tapeWidthMicrons = ${media.width}
416
-
417
- $imgWidthWpf = $img.Width / $DPI * 96
418
- $imgHeightWpf = $img.Height / $DPI * 96
419
-
420
- $ticketXml = @"
421
- <?xml version="1.0" encoding="UTF-8"?>
422
- <psf:PrintTicket xmlns:psf="http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework"
423
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1"
424
- xmlns:psk="http://schemas.microsoft.com/windows/2003/08/printing/printschemakeywords"
425
- xmlns:ns0001="http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords">
426
- <psf:Feature name="psk:PageMediaSize">
427
- <psf:Option name="ns0001:${media.name}">
428
- <psf:ScoredProperty name="psk:MediaSizeWidth">
429
- <psf:Value xsi:type="xsd:integer">${media.width}</psf:Value>
430
- </psf:ScoredProperty>
431
- <psf:ScoredProperty name="psk:MediaSizeHeight">
432
- <psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight" />
433
- </psf:ScoredProperty>
434
- </psf:Option>
435
- </psf:Feature>
436
- <psf:ParameterInit name="psk:PageMediaSizeMediaSizeHeight">
437
- <psf:Value xsi:type="xsd:integer">$labelLengthMicrons</psf:Value>
438
- </psf:ParameterInit>
439
- <psf:Feature name="psk:PageOrientation">
440
- <psf:Option name="psk:Landscape" />
441
- </psf:Feature>
442
- <psf:Feature name="ns0001:PageRollFeedToEndOfSheet">
443
- <psf:Option name="ns0001:FeedToImageEdge" />
444
- </psf:Feature>
445
- <psf:Feature name="psk:PageMediaType">
446
- <psf:Option name="psk:Label" />
447
- </psf:Feature>
448
- <psf:Feature name="ns0001:JobRollCutAtEndOfJob">
449
- <psf:Option name="ns0001:Cut" />
450
- </psf:Feature>
451
- </psf:PrintTicket>
452
- "@
453
-
454
- $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ticketXml)
455
- $memStream = New-Object System.IO.MemoryStream(,$xmlBytes)
456
- $ticket = New-Object System.Printing.PrintTicket($memStream)
457
-
458
- $server = New-Object System.Printing.LocalPrintServer
459
- $queue = $server.GetPrintQueue('${printer}')
460
-
461
- $xpsPath = [System.IO.Path]::GetTempFileName() + ".xps"
462
-
463
- $pageWidth = $labelLengthMicrons / $MICRONS_PER_INCH * 96
464
- $pageHeight = $tapeWidthMicrons / $MICRONS_PER_INCH * 96
465
-
466
- $package = [System.IO.Packaging.Package]::Open($xpsPath, [System.IO.FileMode]::Create)
467
- $xpsDoc = New-Object System.Windows.Xps.Packaging.XpsDocument($package)
468
- $writer = [System.Windows.Xps.Packaging.XpsDocument]::CreateXpsDocumentWriter($xpsDoc)
469
-
470
- $visual = New-Object System.Windows.Media.DrawingVisual
471
- $dc = $visual.RenderOpen()
472
-
473
- $bitmapImg = New-Object System.Windows.Media.Imaging.BitmapImage
474
- $bitmapImg.BeginInit()
475
- $bitmapImg.UriSource = New-Object System.Uri('${tempPath.replace(/\\/g, "\\\\")}')
476
- $bitmapImg.EndInit()
477
-
478
- $yOffset = ($pageHeight - $imgHeightWpf) / 2
479
- $xOffset = 2 / 25.4 * 96 # 2mm left margin in WPF units
480
- $rect = New-Object System.Windows.Rect($xOffset, $yOffset, $imgWidthWpf, $imgHeightWpf)
481
- $dc.DrawImage($bitmapImg, $rect)
482
- $dc.Close()
483
-
484
- $writer.Write($visual, $ticket)
485
-
486
- $xpsDoc.Close()
487
- $package.Close()
488
- $img.Dispose()
489
-
490
- $xpsDocForPrint = New-Object System.Windows.Xps.Packaging.XpsDocument($xpsPath, [System.IO.FileAccess]::Read)
491
- $seq = $xpsDocForPrint.GetFixedDocumentSequence()
492
-
493
- $xpsWriter = [System.Printing.PrintQueue]::CreateXpsDocumentWriter($queue)
494
- $xpsWriter.Write($seq, $ticket)
495
-
496
- $xpsDocForPrint.Close()
497
- Remove-Item $xpsPath -ErrorAction SilentlyContinue
498
- `;
499
-
500
- const ps = spawn("powershell", ["-Command", psScript], { stdio: "pipe" });
501
-
502
- let stdout = "";
503
- let stderr = "";
504
- ps.stdout.on("data", (data) => { stdout += data.toString(); });
505
- ps.stderr.on("data", (data) => { stderr += data.toString(); });
506
-
507
- ps.on("close", (code) => {
508
- if (code === 0) {
509
- resolve();
510
- } else {
511
- reject(new Error(`Print failed: ${stderr}`));
512
- }
513
- });
514
-
515
- ps.on("error", (err) => {
516
- reject(new Error(`Failed to print: ${err.message}`));
517
- });
518
- });
519
- } finally {
520
- if (fs.existsSync(tempPath)) {
521
- fs.unlinkSync(tempPath);
522
- }
523
- }
524
- }
525
-
526
- // Main API functions
527
- export async function render(options: PrintOptions): Promise<Buffer> {
528
- validateOptions(options);
529
- const { tape } = resolveSettings(options);
530
- return renderContent(options, tape);
531
- }
532
-
533
- export async function print(options: PrintOptions): Promise<PrintResult> {
534
- validateOptions(options);
535
- const { tape, printer } = resolveSettings(options);
536
- const image = await renderContent(options, tape);
537
- await printBuffer(image, printer, tape);
538
- return { image };
539
- }
540
-
541
- // Render multiple segments side-by-side into a single image
542
- export async function renderSegments(segments: Segment[], tape?: TapeSize, textHeight?: string, space?: string): Promise<Buffer> {
543
- const config = getConfig();
544
- const effectiveTape: TapeSize = tape ?? config.defaultTape ?? 24;
545
- const tapeSize = TAPE_SIZES[effectiveTape];
546
-
547
- // Render each segment individually
548
- const buffers: Buffer[] = [];
549
- for (const seg of segments) {
550
- if (seg.type === "text") {
551
- buffers.push(await renderText(seg.value, effectiveTape, textHeight));
552
- } else {
553
- buffers.push(await renderQr(seg.value, effectiveTape));
554
- }
555
- }
556
-
557
- if (buffers.length === 1) {
558
- return buffers[0];
559
- }
560
-
561
- // Load all rendered images and trim horizontal whitespace
562
- const images: Array<{ img: Awaited<ReturnType<typeof Jimp.read>>; width: number; height: number }> = [];
563
- for (const buf of buffers) {
564
- const img = await Jimp.read(buf);
565
- // Find leftmost and rightmost non-white columns to trim padding
566
- let left = img.width;
567
- let right = 0;
568
- for (let x = 0; x < img.width; x++) {
569
- for (let y = 0; y < img.height; y++) {
570
- const pixel = img.getPixelColor(x, y);
571
- if (pixel !== 0xffffffff) {
572
- if (x < left) left = x;
573
- if (x > right) right = x;
574
- break;
575
- }
576
- }
577
- }
578
- if (right >= left) {
579
- const margin = 4; // small margin around content
580
- const cropLeft = Math.max(0, left - margin);
581
- const cropRight = Math.min(img.width - 1, right + margin);
582
- const cropWidth = cropRight - cropLeft + 1;
583
- img.crop({ x: cropLeft, y: 0, w: cropWidth, h: img.height });
584
- }
585
- images.push({ img, width: img.width, height: img.height });
586
- }
587
-
588
- // Composite left-to-right
589
- const gap = space ? parseSize(space, tapeSize.height) : 2;
590
- let totalWidth = 0;
591
- for (let i = 0; i < images.length; i++) {
592
- totalWidth += images[i].width;
593
- if (i < images.length - 1) totalWidth += gap;
594
- }
595
-
596
- const canvas = new Jimp({ width: totalWidth, height: tapeSize.height, color: 0xffffffff });
597
- let x = 0;
598
- for (let i = 0; i < images.length; i++) {
599
- const yOffset = Math.round((tapeSize.height - images[i].height) / 2);
600
- canvas.composite(images[i].img as any, x, yOffset);
601
- x += images[i].width + gap;
602
- }
603
-
604
- return canvas.getBuffer("image/png");
605
- }
606
-
607
- // Print multiple segments as a single label
608
- export async function printSegments(segments: Segment[], options?: { tape?: TapeSize; printer?: string; textHeight?: string; space?: string }): Promise<PrintResult> {
609
- const config = getConfig();
610
- const tape: TapeSize = options?.tape ?? config.defaultTape ?? 24;
611
- const printer: string = options?.printer ?? config.defaultPrinter ?? DEFAULT_PRINTER;
612
- const image = await renderSegments(segments, tape, options?.textHeight, options?.space);
613
- await printBuffer(image, printer, tape);
614
- return { image };
615
- }
1
+ /**
2
+ * Brother Label Printer API
3
+ * Programmatic interface for printing labels on Brother P-touch printers.
4
+ *
5
+ * Implements the unified LabelPrinter interface from @bobfrankston/label-core.
6
+ * All printer-agnostic logic (rendering, segments, clipboard, CLI primitives,
7
+ * online-wait) lives in label-core. This file holds the Brother-specific bits:
8
+ * TAPE_SIZES catalog, MEDIA_OPTIONS (CustomMediaSize* names), XPS PrintTicket
9
+ * builder with the Brother namespace, and detectTapeSize.
10
+ */
11
+
12
+ import * as fs from "fs";
13
+ import * as os from "os";
14
+ import * as path from "path";
15
+ import {
16
+ validateContent,
17
+ resolveContent,
18
+ renderSegments as coreRenderSegments,
19
+ listPrinters as coreListPrinters,
20
+ getPrinterStatus,
21
+ waitOnline as coreWaitOnline,
22
+ runPowerShellOrThrow,
23
+ readJsonConfig,
24
+ mergeJsonConfig,
25
+ } from "@bobfrankston/label-core";
26
+ import type {
27
+ LabelPrinter,
28
+ PrintOptions as CorePrintOptions,
29
+ SegmentOptions as CoreSegmentOptions,
30
+ PrintResult as CorePrintResult,
31
+ PrinterStatus,
32
+ PrinterInfo as CorePrinterInfo,
33
+ PrinterConfig as CorePrinterConfig,
34
+ MediaInfo,
35
+ Segment,
36
+ WaitOptions,
37
+ } from "@bobfrankston/label-core";
38
+
39
+ /* ----- Brother-specific types ----- */
40
+
41
+ export type TapeSize = 6 | 9 | 12 | 18 | 24;
42
+ export type Orientation = "landscape" | "portrait";
43
+
44
+ /** Backward-compat: brother-label's config kept the `defaultTape` name. */
45
+ export interface PrinterConfig {
46
+ defaultTape?: TapeSize;
47
+ defaultPrinter?: string;
48
+ }
49
+
50
+ export interface PrinterInfo {
51
+ name: string;
52
+ }
53
+
54
+ /** Brother-specific PrintOptions: same as core but with tape alias for media. */
55
+ export interface PrintOptions extends Omit<CorePrintOptions, "media"> {
56
+ tape?: TapeSize; /** Tape size in mm — alias for core.media */
57
+ orientation?: Orientation; /** Reserved (Brother PT prints landscape only) */
58
+ length?: number; /** Reserved — explicit length in mm */
59
+ }
60
+
61
+ export interface SegmentOptions extends Omit<CoreSegmentOptions, "media"> {
62
+ tape?: TapeSize;
63
+ }
64
+
65
+ export interface PrintResult {
66
+ image: Buffer;
67
+ }
68
+
69
+ export type { Segment };
70
+
71
+ /* ----- Constants ----- */
72
+
73
+ const CONFIG_PATH = path.join(os.homedir(), ".brother-label.json");
74
+ const DEFAULT_PRINTER = "Brother PT-P710BT";
75
+ const DEFAULT_PRINTER_PATTERN = /brother|^pt-|^ql-/i;
76
+ const PRINT_DPI = 300;
77
+ const VALID_TAPES: TapeSize[] = [6, 9, 12, 18, 24];
78
+
79
+ /** Tape sizes: width in pixels @ 300 DPI = continuous direction; height = perpendicular. */
80
+ const TAPE_SIZES: Record<TapeSize, { width: number; height: number }> = {
81
+ 6: { width: 1137, height: 56 },
82
+ 9: { width: 1137, height: 84 },
83
+ 12: { width: 1137, height: 113 },
84
+ 18: { width: 1137, height: 169 },
85
+ 24: { width: 1137, height: 213 },
86
+ };
87
+
88
+ /** Brother CustomMediaSize names + tape widths in microns (XPS PrintTicket). */
89
+ const MEDIA_OPTIONS: Record<TapeSize, { name: string; width: number }> = {
90
+ 6: { name: "CustomMediaSize257", width: 5900 },
91
+ 9: { name: "CustomMediaSize258", width: 9000 },
92
+ 12: { name: "CustomMediaSize259", width: 11900 },
93
+ 18: { name: "CustomMediaSize260", width: 18100 },
94
+ 24: { name: "CustomMediaSize261", width: 24000 },
95
+ };
96
+
97
+ /* ----- Config (backward-compat — note "defaultTape" stays in JSON) ----- */
98
+
99
+ export function getConfig(): PrinterConfig {
100
+ const raw: any = readJsonConfig(CONFIG_PATH);
101
+ if (typeof raw.defaultTape === "string") {
102
+ raw.defaultTape = parseInt(raw.defaultTape.replace("mm", ""), 10) as TapeSize;
103
+ }
104
+ return raw;
105
+ }
106
+
107
+ export function setConfig(config: Partial<PrinterConfig>): void {
108
+ mergeJsonConfig(CONFIG_PATH, config);
109
+ }
110
+
111
+ export function getConfigPath(): string {
112
+ return CONFIG_PATH;
113
+ }
114
+
115
+ /* ----- Printer listing (backward-compat shape: { name }[]) ----- */
116
+
117
+ export async function listPrinters(): Promise<PrinterInfo[]> {
118
+ const ps = await coreListPrinters(DEFAULT_PRINTER_PATTERN);
119
+ return ps.map(p => ({ name: p.name }));
120
+ }
121
+
122
+ /**
123
+ * Auto-detect tape size from printer's default print ticket. Returns null if
124
+ * detection fails. Brother's driver embeds the loaded tape's CustomMediaSize
125
+ * name in the default PrintTicket XML.
126
+ */
127
+ export async function detectTapeSize(printerName?: string): Promise<TapeSize> {
128
+ const config = getConfig();
129
+ const printer = printerName ?? config.defaultPrinter ?? DEFAULT_PRINTER;
130
+ const script = `
131
+ $ErrorActionPreference = 'SilentlyContinue'
132
+ Add-Type -AssemblyName System.Printing | Out-Null
133
+ try {
134
+ $server = New-Object System.Printing.LocalPrintServer
135
+ $queue = $server.GetPrintQueue('${printer.replace(/'/g, "''")}')
136
+ $ticket = $queue.DefaultPrintTicket
137
+ $stream = $ticket.GetXmlStream()
138
+ $reader = New-Object System.IO.StreamReader($stream)
139
+ Write-Output $reader.ReadToEnd()
140
+ $reader.Close()
141
+ } catch {
142
+ Write-Output "ERROR"
143
+ }
144
+ `;
145
+ try {
146
+ const out = await runPowerShellOrThrow(script);
147
+ for (const [size, media] of Object.entries(MEDIA_OPTIONS)) {
148
+ if (out.includes(media.name)) return parseInt(size, 10) as TapeSize;
149
+ }
150
+ } catch { /* fall through */ }
151
+ return null;
152
+ }
153
+
154
+ /* ----- Brother LabelPrinter implementation ----- */
155
+
156
+ class BrotherPrinterImpl implements LabelPrinter {
157
+ readonly driverName = "brother";
158
+
159
+ getConfig(): CorePrinterConfig {
160
+ const c = getConfig();
161
+ return {
162
+ defaultPrinter: c.defaultPrinter,
163
+ defaultMedia: c.defaultTape !== undefined ? String(c.defaultTape) : undefined,
164
+ };
165
+ }
166
+ setConfig(config: Partial<CorePrinterConfig>): void {
167
+ const update: Partial<PrinterConfig> = {};
168
+ if (config.defaultPrinter !== undefined) update.defaultPrinter = config.defaultPrinter;
169
+ if (config.defaultMedia !== undefined) {
170
+ const t = parseInt(config.defaultMedia, 10) as TapeSize;
171
+ if (VALID_TAPES.includes(t)) update.defaultTape = t;
172
+ }
173
+ setConfig(update);
174
+ }
175
+ getConfigPath(): string {
176
+ return CONFIG_PATH;
177
+ }
178
+
179
+ listMedia(): MediaInfo[] {
180
+ return VALID_TAPES.map(t => ({
181
+ id: String(t),
182
+ name: `${t}mm tape`,
183
+ widthPx: TAPE_SIZES[t].height, /** "media width" (perpendicular to feed) */
184
+ lengthPx: 0, /** continuous tape length determined by content */
185
+ continuous: true,
186
+ }));
187
+ }
188
+ getMedia(id: string): MediaInfo {
189
+ const t = parseInt(id.replace("mm", ""), 10) as TapeSize;
190
+ if (!VALID_TAPES.includes(t)) return null;
191
+ return {
192
+ id: String(t), name: `${t}mm tape`,
193
+ widthPx: TAPE_SIZES[t].height, lengthPx: 0, continuous: true,
194
+ };
195
+ }
196
+ async detectMedia(printerName?: string): Promise<MediaInfo> {
197
+ const t = await detectTapeSize(printerName);
198
+ return t ? this.getMedia(String(t)) : null;
199
+ }
200
+
201
+ async listPrinters(): Promise<CorePrinterInfo[]> {
202
+ return coreListPrinters(DEFAULT_PRINTER_PATTERN);
203
+ }
204
+ async getStatus(printerName?: string): Promise<PrinterStatus> {
205
+ const printer = printerName ?? this.getConfig().defaultPrinter ?? DEFAULT_PRINTER;
206
+ return getPrinterStatus(printer);
207
+ }
208
+ async waitOnline(printerName?: string, opts: WaitOptions = {}): Promise<PrinterStatus> {
209
+ const printer = printerName ?? this.getConfig().defaultPrinter ?? DEFAULT_PRINTER;
210
+ return coreWaitOnline(printer, opts, DEFAULT_PRINTER_PATTERN);
211
+ }
212
+
213
+ async render(opts: CorePrintOptions): Promise<Buffer> {
214
+ validateContent(opts);
215
+ const tape = await this.resolveTape(opts.media);
216
+ return resolveContent(opts, TAPE_SIZES[tape].height, tape);
217
+ }
218
+
219
+ async print(opts: CorePrintOptions): Promise<CorePrintResult> {
220
+ validateContent(opts);
221
+ const printer = opts.printer ?? this.getConfig().defaultPrinter ?? DEFAULT_PRINTER;
222
+ const tape = await this.resolveTape(opts.media);
223
+ const image = await resolveContent(opts, TAPE_SIZES[tape].height, tape);
224
+ await ensureOnline(printer, opts);
225
+ await printBuffer(image, printer, tape);
226
+ return { image, media: this.getMedia(String(tape)), printer };
227
+ }
228
+
229
+ async renderSegments(segments: Segment[], opts: CoreSegmentOptions = {}): Promise<Buffer> {
230
+ const tape = await this.resolveTape(opts.media);
231
+ return coreRenderSegments(segments, TAPE_SIZES[tape].height, opts.textHeight, opts.space);
232
+ }
233
+ async printSegments(segments: Segment[], opts: CoreSegmentOptions = {}): Promise<CorePrintResult> {
234
+ const printer = opts.printer ?? this.getConfig().defaultPrinter ?? DEFAULT_PRINTER;
235
+ const tape = await this.resolveTape(opts.media);
236
+ const image = await coreRenderSegments(segments, TAPE_SIZES[tape].height, opts.textHeight, opts.space);
237
+ await ensureOnline(printer, opts);
238
+ await printBuffer(image, printer, tape);
239
+ return { image, media: this.getMedia(String(tape)), printer };
240
+ }
241
+
242
+ private async resolveTape(media?: string): Promise<TapeSize> {
243
+ if (media !== undefined) {
244
+ const t = parseInt(media.replace("mm", ""), 10) as TapeSize;
245
+ if (!VALID_TAPES.includes(t)) throw new Error(`Invalid tape size: ${media}. Valid: ${VALID_TAPES.join(", ")}`);
246
+ return t;
247
+ }
248
+ const cfg = getConfig();
249
+ if (cfg.defaultTape) return cfg.defaultTape;
250
+ const detected = await detectTapeSize();
251
+ return detected ?? 24;
252
+ }
253
+ }
254
+
255
+ /** Public Brother singleton (implements LabelPrinter). */
256
+ export const brotherPrinter: LabelPrinter = new BrotherPrinterImpl();
257
+
258
+ /* ----- Backward-compatible function exports ----- */
259
+
260
+ /**
261
+ * Translate brother-label PrintOptions (with tape) to core (with media).
262
+ */
263
+ function toCore(opts: PrintOptions): CorePrintOptions {
264
+ const out: CorePrintOptions = { ...opts };
265
+ if (opts.tape !== undefined && out.media === undefined) {
266
+ out.media = String(opts.tape);
267
+ }
268
+ delete (out as any).tape;
269
+ return out;
270
+ }
271
+ function toCoreSeg(opts: SegmentOptions = {}): CoreSegmentOptions {
272
+ const out: CoreSegmentOptions = { ...opts };
273
+ if (opts.tape !== undefined && out.media === undefined) out.media = String(opts.tape);
274
+ delete (out as any).tape;
275
+ return out;
276
+ }
277
+
278
+ export async function render(options: PrintOptions): Promise<Buffer> {
279
+ return brotherPrinter.render(toCore(options));
280
+ }
281
+
282
+ export async function print(options: PrintOptions): Promise<PrintResult> {
283
+ const r = await brotherPrinter.print(toCore(options));
284
+ return { image: r.image };
285
+ }
286
+
287
+ export async function renderSegments(segments: Segment[], tape?: TapeSize, textHeight?: string, space?: string): Promise<Buffer> {
288
+ return brotherPrinter.renderSegments(segments, {
289
+ media: tape !== undefined ? String(tape) : undefined,
290
+ textHeight, space,
291
+ });
292
+ }
293
+
294
+ export async function printSegments(segments: Segment[], options?: SegmentOptions): Promise<PrintResult> {
295
+ const r = await brotherPrinter.printSegments(segments, toCoreSeg(options));
296
+ return { image: r.image };
297
+ }
298
+
299
+ /* ----- Brother XPS print path (kept here, Brother-specific) ----- */
300
+
301
+ async function ensureOnline(printer: string, opts: { wait?: boolean; waitTimeoutMs?: number; waitIntervalMs?: number; onWaiting?: CorePrintOptions["onWaiting"]; log?: CorePrintOptions["log"] }): Promise<void> {
302
+ const status = await getPrinterStatus(printer);
303
+ if (status.online) return;
304
+ if (opts.wait === false) {
305
+ throw new Error(`Printer "${printer}" is offline (${status.statusText}${status.error ? ", " + status.error : ""}). Use wait:true to wait, or pick another printer.`);
306
+ }
307
+ if (opts.log) opts.log(`[brother-label] ${printer} offline (${status.statusText}); waiting...`);
308
+ await coreWaitOnline(printer, {
309
+ timeoutMs: opts.waitTimeoutMs,
310
+ intervalMs: opts.waitIntervalMs,
311
+ onWaiting: opts.onWaiting,
312
+ log: opts.log,
313
+ }, DEFAULT_PRINTER_PATTERN);
314
+ }
315
+
316
+ /**
317
+ * Print a single image buffer using the Brother XPS PrintTicket path.
318
+ * Builds an XPS document with a custom CustomMediaSize for the tape, then
319
+ * sends it to the print queue.
320
+ */
321
+ async function printBuffer(imageBuffer: Buffer, printer: string, tape: TapeSize): Promise<void> {
322
+ const media = MEDIA_OPTIONS[tape];
323
+ const tempPath = path.join(os.tmpdir(), `label-${Date.now()}.png`);
324
+ fs.writeFileSync(tempPath, imageBuffer);
325
+ const psPath = tempPath.replace(/\\/g, "\\\\");
326
+
327
+ const script = `
328
+ $ErrorActionPreference = 'Stop'
329
+ Add-Type -AssemblyName System.Drawing
330
+ Add-Type -AssemblyName System.Printing
331
+ Add-Type -AssemblyName ReachFramework
332
+ Add-Type -AssemblyName PresentationCore
333
+
334
+ $img = [System.Drawing.Image]::FromFile('${psPath}')
335
+
336
+ $DPI = ${PRINT_DPI}
337
+ $MICRONS_PER_INCH = 25400
338
+
339
+ $labelLengthMicrons = [int]($img.Width / $DPI * $MICRONS_PER_INCH) + 3000
340
+ $tapeWidthMicrons = ${media.width}
341
+
342
+ $imgWidthWpf = $img.Width / $DPI * 96
343
+ $imgHeightWpf = $img.Height / $DPI * 96
344
+
345
+ $ticketXml = @"
346
+ <?xml version="1.0" encoding="UTF-8"?>
347
+ <psf:PrintTicket xmlns:psf="http://schemas.microsoft.com/windows/2003/08/printing/printschemaframework"
348
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1"
349
+ xmlns:psk="http://schemas.microsoft.com/windows/2003/08/printing/printschemakeywords"
350
+ xmlns:ns0001="http://schemas.brother.info/mfc/printing/2006/11/printschemakeywords">
351
+ <psf:Feature name="psk:PageMediaSize">
352
+ <psf:Option name="ns0001:${media.name}">
353
+ <psf:ScoredProperty name="psk:MediaSizeWidth">
354
+ <psf:Value xsi:type="xsd:integer">${media.width}</psf:Value>
355
+ </psf:ScoredProperty>
356
+ <psf:ScoredProperty name="psk:MediaSizeHeight">
357
+ <psf:ParameterRef name="psk:PageMediaSizeMediaSizeHeight" />
358
+ </psf:ScoredProperty>
359
+ </psf:Option>
360
+ </psf:Feature>
361
+ <psf:ParameterInit name="psk:PageMediaSizeMediaSizeHeight">
362
+ <psf:Value xsi:type="xsd:integer">$labelLengthMicrons</psf:Value>
363
+ </psf:ParameterInit>
364
+ <psf:Feature name="psk:PageOrientation">
365
+ <psf:Option name="psk:Landscape" />
366
+ </psf:Feature>
367
+ <psf:Feature name="ns0001:PageRollFeedToEndOfSheet">
368
+ <psf:Option name="ns0001:FeedToImageEdge" />
369
+ </psf:Feature>
370
+ <psf:Feature name="psk:PageMediaType">
371
+ <psf:Option name="psk:Label" />
372
+ </psf:Feature>
373
+ <psf:Feature name="ns0001:JobRollCutAtEndOfJob">
374
+ <psf:Option name="ns0001:Cut" />
375
+ </psf:Feature>
376
+ </psf:PrintTicket>
377
+ "@
378
+
379
+ $xmlBytes = [System.Text.Encoding]::UTF8.GetBytes($ticketXml)
380
+ $memStream = New-Object System.IO.MemoryStream(,$xmlBytes)
381
+ $ticket = New-Object System.Printing.PrintTicket($memStream)
382
+
383
+ $server = New-Object System.Printing.LocalPrintServer
384
+ $queue = $server.GetPrintQueue('${printer.replace(/'/g, "''")}')
385
+
386
+ $xpsPath = [System.IO.Path]::GetTempFileName() + ".xps"
387
+
388
+ $pageWidth = $labelLengthMicrons / $MICRONS_PER_INCH * 96
389
+ $pageHeight = $tapeWidthMicrons / $MICRONS_PER_INCH * 96
390
+
391
+ $package = [System.IO.Packaging.Package]::Open($xpsPath, [System.IO.FileMode]::Create)
392
+ $xpsDoc = New-Object System.Windows.Xps.Packaging.XpsDocument($package)
393
+ $writer = [System.Windows.Xps.Packaging.XpsDocument]::CreateXpsDocumentWriter($xpsDoc)
394
+
395
+ $visual = New-Object System.Windows.Media.DrawingVisual
396
+ $dc = $visual.RenderOpen()
397
+
398
+ $bitmapImg = New-Object System.Windows.Media.Imaging.BitmapImage
399
+ $bitmapImg.BeginInit()
400
+ $bitmapImg.UriSource = New-Object System.Uri('${psPath}')
401
+ $bitmapImg.EndInit()
402
+
403
+ $yOffset = ($pageHeight - $imgHeightWpf) / 2
404
+ $xOffset = 2 / 25.4 * 96
405
+ $rect = New-Object System.Windows.Rect($xOffset, $yOffset, $imgWidthWpf, $imgHeightWpf)
406
+ $dc.DrawImage($bitmapImg, $rect)
407
+ $dc.Close()
408
+
409
+ $writer.Write($visual, $ticket)
410
+
411
+ $xpsDoc.Close()
412
+ $package.Close()
413
+ $img.Dispose()
414
+
415
+ $xpsDocForPrint = New-Object System.Windows.Xps.Packaging.XpsDocument($xpsPath, [System.IO.FileAccess]::Read)
416
+ $seq = $xpsDocForPrint.GetFixedDocumentSequence()
417
+
418
+ $xpsWriter = [System.Printing.PrintQueue]::CreateXpsDocumentWriter($queue)
419
+ $xpsWriter.Write($seq, $ticket)
420
+
421
+ $xpsDocForPrint.Close()
422
+ Remove-Item $xpsPath -ErrorAction SilentlyContinue
423
+ `;
424
+ try {
425
+ await runPowerShellOrThrow(script);
426
+ } finally {
427
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
428
+ }
429
+ }