From 52eb0147fd223b5219cbfe7e3daf3139cf2cd6ae Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 7 Sep 2026 14:02:01 +1000 Subject: [PATCH] Add printPng drop-in to replace the brother_ql subprocess Takes the PNG renderBadgePng already produces, separates black and red planes, and sends the job over TCP. Adds a serial queue per printer host so simultaneous sign-ins cannot collide on the printer's single connection. Not wired into printer.js yet. --- src/printing/ql-print.js | 185 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/printing/ql-print.js diff --git a/src/printing/ql-print.js b/src/printing/ql-print.js new file mode 100644 index 0000000..4c608cf --- /dev/null +++ b/src/printing/ql-print.js @@ -0,0 +1,185 @@ +import { createCanvas, loadImage } from '@napi-rs/canvas'; + +import { getMedia, buildJob, PIXEL_WIDTH } from './ql-raster.js'; +import { queryStatus, 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, minus + * writing every badge to a temp file, and with the printer's actual status + * frames instead of a parsed traceback — so "cover open" and "wrong roll" are + * reported before a label is wasted rather than guessed at from stderr + * afterwards. + * + * 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] + * @returns {Promise<{ok: 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 current = await queryStatus(host, { port }); + const problem = describeProblem(current) || checkMediaMatches(current, 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 = describeProblem(last); + + if (problem) return { ok: false, message: problem }; + if (!result.completed && result.timedOut) { + return { + ok: false, + message: 'The printer took the label but never confirmed it finished.', + }; + } + return { ok: true, message: `Printed to ${host}` }; + } catch (err) { + return { ok: false, message: err.message }; + } + }); +} + +/** + * Ask the printer how it is, without printing. Worth polling from the admin + * console so reception sees "roll empty" before a visitor is at the desk. + */ +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); + + try { + const current = await queryStatus(host, { port }); + const problem = describeProblem(current) || checkMediaMatches(current, media); + return { + online: true, + ready: !problem, + message: problem || 'Ready', + mediaWidthMm: current.mediaWidthMm, + mediaType: current.mediaType, + }; + } catch (err) { + return { online: false, ready: false, message: err.message }; + } +} + +export { LABEL_TO_MEDIA };