Public Access
382 lines
13 KiB
JavaScript
382 lines
13 KiB
JavaScript
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));
|
|
});
|
|
}
|