Public Access
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.
This commit is contained in:
@@ -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 };
|
||||||
Reference in New Issue
Block a user