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