import { createCanvas, loadImage } from '@napi-rs/canvas'; import { getMedia, buildJob, PIXEL_WIDTH } from './ql-raster.js'; import { probeStatus, sendJob, DEFAULT_PORT } from './ql-transport.js'; import { describeProblem, checkMediaMatches } from './ql-status.js'; /** * Sends a rendered badge to a Brother QL over the network, speaking the * printer's raster protocol directly. * * This is the drop-in replacement for shelling out to the Python brother_ql * CLI in printer.js. Same job, minus a Python runtime in the image and minus * writing every badge to a temp file. * * What it does NOT buy us is reliable printer state. The QL-820NWB accepts * jobs on port 9100 but stays silent over TCP; status frames only come back * over USB. So roll type, roll level and cover state are unknowable from here, * exactly as they were with brother_ql. Status handling below is best effort: * used when offered, never required. * * Input is the PNG renderBadgePng() already produces, so the badge layout, * rotation and vertical centring are untouched. */ /** Maps the site's printer_label value onto a media definition. */ const LABEL_TO_MEDIA = { 62: { media: '62', red: false }, '62red': { media: '62', red: true }, }; /** * The QL accepts one TCP connection at a time and has no job spooler worth the * name. Two visitors signing in together would otherwise collide: a refused * connection, a half-printed label, or both. One queue per printer host. * * Note this is in-process. If the app is ever scaled past one instance against * a single printer, this needs to become a lock in SQLite instead. */ const queues = new Map(); function enqueue(host, task) { const previous = queues.get(host) || Promise.resolve(); // Swallow the previous failure so one bad job doesn't poison the queue. const next = previous.catch(() => {}).then(task); queues.set( host, next.catch(() => {}) ); return next; } /** * Decode a PNG into the black and red ink maps the printer wants. * * The renderer draws accent elements as pure #ff0000, so red separation is * just a colour test. A dot never lands in both planes; the printer would burn * it twice and the result is muddy brown. */ async function pngToPlanes(png, media, { red, threshold = 180 }) { const image = await loadImage(png); const width = image.width; const height = image.height; if (width > media.printableDots) { throw new Error( `Badge is ${width} dots wide but ${media.label} only prints ${media.printableDots}.` ); } const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0); const { data } = ctx.getImageData(0, 0, width, height); // Inset so the image lands on the paper rather than off the head's edge. const xOffset = PIXEL_WIDTH - width - media.offsetR; const size = PIXEL_WIDTH * height; const black = new Uint8Array(size); const redPlane = red ? new Uint8Array(size) : null; for (let y = 0; y < height; y++) { const srcRow = y * width * 4; const dstRow = y * PIXEL_WIDTH + xOffset; for (let x = 0; x < width; x++) { const i = srcRow + x * 4; const a = data[i + 3] / 255; const r = data[i] * a + 255 * (1 - a); const g = data[i + 1] * a + 255 * (1 - a); const b = data[i + 2] * a + 255 * (1 - a); if (red && r >= 90 && r - Math.max(g, b) >= 60) { redPlane[dstRow + x] = 1; } else if (0.299 * r + 0.587 * g + 0.114 * b <= threshold) { black[dstRow + x] = 1; } } } return { width: PIXEL_WIDTH, height, black, red: redPlane }; } /** * Print one badge. * * @param {Buffer} png Output of renderBadgePng() * @param {Object} options * @param {string} options.host * @param {number} [options.port=9100] * @param {string} [options.label='62'] The site's printer_label value * @param {boolean} [options.cut=true] * @param {number} [options.threshold] Luminance cutover, 0-255; higher is bolder * @param {boolean} [options.checkMedia=true] Best effort; skipped if the printer is mute * @returns {Promise<{ok: boolean, confirmed?: boolean, message: string}>} */ export async function printPng(png, options = {}) { const { host, port = DEFAULT_PORT, label = '62', cut = true, threshold, checkMedia = true, compress = false, } = options; if (!host) return { ok: false, message: 'No printer host is configured for this site.' }; const mapping = LABEL_TO_MEDIA[label] || LABEL_TO_MEDIA['62']; const media = getMedia(mapping.media); return enqueue(host, async () => { try { if (checkMedia) { const probe = await probeStatus(host, { port }); if (!probe.reachable) { return { ok: false, message: probe.error || `Cannot reach printer at ${host}:${port}`, }; } // A silent printer is the normal case over TCP, so absent status must // never block the job — the visitor is already signed in and waiting. if (probe.status) { const problem = describeProblem(probe.status) || checkMediaMatches(probe.status, media); if (problem) return { ok: false, message: problem }; } } const page = await pngToPlanes(png, media, { red: mapping.red, threshold }); const job = buildJob([page], { media: media.id, cut, compress }); const result = await sendJob(host, job, { port }); const last = result.frames[result.frames.length - 1]; const problem = last ? describeProblem(last) : null; if (problem) return { ok: false, message: problem }; // Without status frames, "delivered" is as strong a claim as we can make. // Don't dress that up as confirmation the label physically came out. return { ok: true, confirmed: result.statusAvailable, message: result.statusAvailable ? `Printed to ${host}` : `Sent to ${host} (this printer does not confirm over the network)`, }; } catch (err) { return { ok: false, message: err.message }; } }); } /** * Ask the printer how it is, without printing. * * `ready` is tri-state: true, false, or null for "reachable but it won't say", * which is the normal answer over the network. Treat null as unknown in the * admin console rather than colouring it green or red. */ export async function printerHealth({ host, port = DEFAULT_PORT, label = '62' }) { if (!host) return { online: false, ready: false, message: 'No printer configured' }; const mapping = LABEL_TO_MEDIA[label] || LABEL_TO_MEDIA['62']; const media = getMedia(mapping.media); const probe = await probeStatus(host, { port }); if (!probe.reachable) { return { online: false, ready: false, message: probe.error || 'Printer did not answer' }; } // The usual case on this hardware: reachable, but mute. Report that honestly // rather than dressing silence up as either health or failure. if (!probe.status) { return { online: true, ready: null, message: 'Reachable. This printer does not report status over the network, so ' + 'roll type, roll level and cover state cannot be checked from here.', }; } const problem = describeProblem(probe.status) || checkMediaMatches(probe.status, media); return { online: true, ready: !problem, message: problem || 'Ready', mediaWidthMm: probe.status.mediaWidthMm, mediaType: probe.status.mediaType, }; } export { LABEL_TO_MEDIA };