import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFile } from 'node:child_process'; import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas'; import config from './config.js'; import { photoAbsolutePath } from './photos.js'; /** * Printing happens on the server, not in the kiosk browser. * * The badge is drawn to a bitmap here and pushed straight to the printer over the * network, so the tablet at the door needs no printer driver, no default printer * and no print dialog — and a second kiosk can be added without configuring * anything on it. * * The QL-820NWB prints 696 dots across a 62 mm roll at 300 dpi. That figure is * fixed by the printer, so the bitmap is always 696 wide however the badge is * laid out; rotation is applied to the finished image, not to the layout. */ const DPI = 300; const DOTS_ACROSS_62MM = 696; const FONT = 'Liberation Sans, DejaVu Sans, Arial, sans-serif'; const mm = (value) => Math.round((value / 25.4) * DPI); /** Per-site outcome of the last print, surfaced in the admin console. */ const lastResult = new Map(); export function printerStatus(siteId) { return lastResult.get(Number(siteId)) || null; } function note(siteId, ok, message) { lastResult.set(Number(siteId), { ok, message, at: new Date().toISOString() }); } export function isConfigured(site) { return Boolean(site?.printer_enabled && site?.printer_host); } /** * brother_ql's media id for the roll that is actually loaded. * * This is set explicitly rather than inferred from the red styling option. A * two-colour job sent to a plain roll is rejected by the printer with "Wrong * Roll Type", which gives no clue that a colour checkbox caused it. */ export const ROLL_TYPES = { '62': '62 mm continuous, black only', '62red': '62 mm continuous, black and red (DK-22251)', }; export function labelFor(site) { const label = site?.printer_label; return Object.hasOwn(ROLL_TYPES, label) ? label : '62'; } /** Red ink only exists on the two-colour roll; anywhere else it prints dark grey. */ export function accentWillPrintRed(site) { return Boolean(site?.badge_accent) && labelFor(site) === '62red'; } /* ------------------------------------------------------------ rendering */ function wrapText(ctx, text, maxWidth, maxLines) { const words = String(text || '').split(/\s+/).filter(Boolean); const lines = []; let line = ''; for (const word of words) { const candidate = line ? `${line} ${word}` : word; if (ctx.measureText(candidate).width <= maxWidth || !line) { line = candidate; } else { lines.push(line); line = word; if (lines.length === maxLines - 1) break; } } if (line) lines.push(line); return lines.slice(0, maxLines); } /** * Draws the badge at its designed size in dots. Mirrors the browser badge so the * preview and the printed label agree. */ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) { const unit = Math.min(widthDots, heightDots); const pad = Math.round(unit * 0.07); const black = '#000000'; const red = accent ? '#ff0000' : '#000000'; if (startY !== null) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, widthDots, heightDots); } const portrait = heightDots >= widthDots * 1.2; const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115))); const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062))); let photo = null; const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null; if (abs) { try { photo = await loadImage(abs); } catch { photo = null; } } const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0; let cursorY = startY === null ? pad : startY; let textLeft = pad; let textWidth = widthDots - pad * 2; const paint = startY !== null; if (photo) { if (portrait) { const x = Math.round((widthDots - photoSize) / 2); if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize); if (paint) { ctx.strokeStyle = black; ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.strokeRect(x, cursorY, photoSize, photoSize); } cursorY += photoSize + Math.round(unit * 0.05); } else { const y = Math.round((heightDots - photoSize) / 2); if (paint) { ctx.drawImage(photo, pad, y, photoSize, photoSize); ctx.strokeStyle = black; ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); ctx.strokeRect(pad, y, photoSize, photoSize); } textLeft = pad + photoSize + Math.round(unit * 0.05); textWidth = widthDots - textLeft - pad; } } ctx.textBaseline = 'top'; ctx.textAlign = portrait ? 'center' : 'left'; const centreX = portrait ? widthDots / 2 : textLeft; // Site name, with a rule under it. ctx.fillStyle = red; ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`; if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth); cursorY += Math.round(bodySize * 0.8 * 1.3); if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35)))); cursorY += Math.round(unit * 0.04); // Name, wrapped to at most two lines. ctx.fillStyle = black; ctx.font = `bold ${nameSize}px ${FONT}`; const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2); for (const line of nameLines) { if (paint) ctx.fillText(line, centreX, cursorY, textWidth); cursorY += Math.round(nameSize * 1.05); } cursorY += Math.round(unit * 0.04); // Detail rows. const timeIn = new Date(visit.signed_in_at); const rows = [ `Visiting ${visit.host_name}`, `In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`, ]; ctx.font = `${bodySize}px ${FONT}`; ctx.fillStyle = black; for (const row of rows) { for (const line of wrapText(ctx, row, textWidth, 2)) { if (paint) ctx.fillText(line, centreX, cursorY, textWidth); cursorY += Math.round(bodySize * 1.3); } } // Check status: boxed and in the accent colour when they hold nothing. if (visit.check_type === 'NONE') { const label = 'No WWCC / VIT'; ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`; const w = ctx.measureText(label).width + bodySize; const x = portrait ? Math.round((widthDots - w) / 2) : textLeft; const h = Math.round(bodySize * 1.5); if (paint) { ctx.strokeStyle = red; ctx.lineWidth = Math.max(2, Math.round(mm(0.35))); ctx.strokeRect(x, cursorY, w, h); ctx.fillStyle = red; ctx.textAlign = 'center'; ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25)); ctx.textAlign = portrait ? 'center' : 'left'; } cursorY += h + Math.round(bodySize * 0.3); } else { ctx.fillStyle = black; ctx.font = `${bodySize}px ${FONT}`; if (paint) { ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth); } cursorY += Math.round(bodySize * 1.3); } if (site.badge_note) { ctx.fillStyle = black; ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`; for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) { if (paint) ctx.fillText(line, centreX, cursorY, textWidth); cursorY += Math.round(bodySize * 1.1); } } return cursorY - (startY === null ? pad : startY); } /** * Produces the PNG that gets sent to the printer. * * The bitmap is always 696 dots across, because that is the printer's fixed head * width on a 62 mm roll. With rotation the badge is laid out along the length of * the label instead and the finished image is turned, so the content still lands * within those 696 dots. */ export async function renderBadgePng(visit, site) { const rotate = Number(site.printer_rotate) || 0; const lengthMm = Number(site.badge_height_mm) || 90; const turned = rotate === 90 || rotate === 270; const acrossDots = DOTS_ACROSS_62MM; const alongDots = mm(lengthMm); // Design canvas: swapped when the badge is laid out along the label. const designW = turned ? alongDots : acrossDots; const designH = turned ? acrossDots : alongDots; const design = createCanvas(designW, designH); const ctx = design.getContext('2d'); // Only paint red when the loaded roll can actually print it. const accent = accentWillPrintRed(site); // Measure first, then draw the block centred down the label. Without this the // content hugs the top and leaves a wide blank strip at the bottom of every badge. const used = await drawBadge(ctx, designW, designH, visit, site, accent, null); const pad = Math.round(Math.min(designW, designH) * 0.07); const startY = Math.max(pad, Math.round((designH - used) / 2)); await drawBadge(ctx, designW, designH, visit, site, accent, startY); if (!rotate) return design.toBuffer('image/png'); const out = createCanvas(turned ? acrossDots : designW, turned ? alongDots : designH); const outCtx = out.getContext('2d'); outCtx.fillStyle = '#ffffff'; outCtx.fillRect(0, 0, out.width, out.height); outCtx.translate(out.width / 2, out.height / 2); outCtx.rotate((rotate * Math.PI) / 180); outCtx.drawImage(design, -designW / 2, -designH / 2); return out.toBuffer('image/png'); } /* -------------------------------------------------------------- sending */ /** * brother_ql reports failures as a Python traceback. Nobody at a front desk can * act on that, so the useful last line is pulled out and the common network * failures are rewritten as something with a next step. */ function explainPrintError(output, host) { const lines = String(output || '') .split('\n') .map((l) => l.trim()) .filter((l) => l && !/^deprecation warning/i.test(l)); const last = lines[lines.length - 1] || ''; if (/Connection refused/i.test(last)) { return `${host} refused the connection. Check the printer is switched on and that port 9100 is the right one.`; } if (/timed out|timeout/i.test(last)) { return `${host} did not answer. Check the IP address and that the printer is on the same network as the server.`; } if (/No route to host|Network is unreachable/i.test(last)) { return `${host} cannot be reached from the server. Check the address and any firewall between them.`; } if (/Name or service not known|getaddrinfo/i.test(last)) { return `${host} could not be resolved. Use the printer's IP address rather than a name.`; } if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) { return 'The printer rejected the label size. Check the roll loaded matches the badge settings.'; } if (/wrong roll|WrongMedia|media/i.test(last)) { return 'The printer says the roll is wrong. Check "Roll loaded in the printer" matches what is actually in the machine — a black and red job is refused on a plain roll.'; } return last || 'The printer did not accept the job.'; } function runBrotherQl(args, timeoutMs) { return new Promise((resolve, reject) => { execFile( config.printing.command, args, { timeout: timeoutMs, env: { ...process.env, BROTHER_QL_PRINTER: '', BROTHER_QL_MODEL: '' } }, (err, stdout, stderr) => { const output = `${stdout || ''}${stderr || ''}`.trim(); if (err) { if (err.code === 'ENOENT') { return reject( new Error( `${config.printing.command} is not installed in the container. Rebuild the image, or set PRINT_COMMAND.` ) ); } if (err.killed) return reject(new Error('The printer did not respond in time.')); return reject(new Error(output || err.message)); } resolve(output); } ); }); } /** * Renders and prints one badge. Resolves with a short description on success and * rejects with something an admin can act on. */ export async function printBadge(visit, site) { if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.'); const png = await renderBadgePng(visit, site); const file = path.join(os.tmpdir(), `badge-${crypto.randomBytes(6).toString('hex')}.png`); fs.writeFileSync(file, png); const port = Number(site.printer_port) || 9100; const target = `tcp://${site.printer_host}:${port}`; try { await runBrotherQl( [ '--backend', 'network', '--model', site.printer_model || 'QL-820NWB', '--printer', target, 'print', '--label', labelFor(site), file, ], config.printing.timeoutMs ); note(site.id, true, `Printed to ${site.printer_host}`); return { ok: true, target }; } catch (err) { const friendly = explainPrintError(err.message, site.printer_host); note(site.id, false, friendly); throw new Error(friendly); } finally { fs.rm(file, { force: true }, () => {}); } } /** A sample badge, for checking the printer and the layout without a real visit. */ export function sampleVisit(site) { return { id: 0, first_name: 'Sample', last_name: 'Visitor', host_name: 'Jess Rogerson', check_type: 'NONE', check_number: null, photo_path: null, signed_in_at: new Date().toISOString(), site_name: site.name, }; } export function available() { return new Promise((resolve) => { execFile(config.printing.command, ['--version'], (err) => resolve(!err)); }); }