Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8664a1dd13 | ||
|
|
cdc1bc4264 | ||
|
|
a9e07b324d |
+46
-26
@@ -6,6 +6,8 @@ import { execFile } from 'node:child_process';
|
||||
import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
|
||||
import config from './config.js';
|
||||
import { photoAbsolutePath } from './photos.js';
|
||||
import { ditherPhoto } from './printing/dither.js';
|
||||
import { printPng, printerHealth } from './printing/ql-print.js';
|
||||
|
||||
/**
|
||||
* Printing happens on the server, not in the kiosk browser.
|
||||
@@ -113,6 +115,17 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
||||
}
|
||||
|
||||
const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0;
|
||||
|
||||
// Thermal heads print pure black or nothing, so a photo has to be reduced to
|
||||
// 1-bit before it lands on the canvas — otherwise the plane conversion
|
||||
// thresholds it into a solid blob. Done once here rather than at each draw
|
||||
// site, and at exactly photoSize: rescaling a dithered image resamples the
|
||||
// dot pattern back into greys and undoes the whole thing.
|
||||
//
|
||||
// Skipped on the measuring pass, which never paints.
|
||||
if (photo && photoSize > 0 && startY !== null) {
|
||||
photo = ditherPhoto(photo, photoSize);
|
||||
}
|
||||
let cursorY = startY === null ? pad : startY;
|
||||
let textLeft = pad;
|
||||
let textWidth = widthDots - pad * 2;
|
||||
@@ -330,33 +343,24 @@ 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 }, () => {});
|
||||
}
|
||||
// The roll decides whether the job is two-colour, not the accent setting.
|
||||
// A DK-22251 roll refuses a monochrome job even when the badge has no red
|
||||
// on it, so labelFor() is passed straight through and ql-print maps it.
|
||||
const result = await printPng(png, {
|
||||
host: site.printer_host,
|
||||
port,
|
||||
label: labelFor(site),
|
||||
});
|
||||
|
||||
note(site.id, result.ok, result.message);
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
|
||||
// confirmed is false when the printer accepted the bytes but never said the
|
||||
// label came out. Over the network that is the normal case, not a fault.
|
||||
return { ok: true, confirmed: Boolean(result.confirmed), target };
|
||||
}
|
||||
|
||||
/** A sample badge, for checking the printer and the layout without a real visit. */
|
||||
@@ -375,7 +379,23 @@ export function sampleVisit(site) {
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return new Promise((resolve) => {
|
||||
execFile(config.printing.command, ['--version'], (err) => resolve(!err));
|
||||
// Kept for callers that only ask "can this server print at all?". The
|
||||
// external brother_ql binary is no longer involved, so the answer is always
|
||||
// yes; use health(site) to ask about a specific printer.
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability and, where the printer will say, roll state.
|
||||
*
|
||||
* ready is tri-state: true, false, or null meaning "reachable but it won't
|
||||
* tell us". Null is the normal answer over the network — show it as unknown
|
||||
* in the admin console rather than green or red.
|
||||
*/
|
||||
export function health(site) {
|
||||
return printerHealth({
|
||||
host: site?.printer_host,
|
||||
port: Number(site?.printer_port) || 9100,
|
||||
label: labelFor(site),
|
||||
});
|
||||
}
|
||||
|
||||
+70
-9
@@ -9,7 +9,7 @@ import { createCanvas } from '@napi-rs/canvas';
|
||||
* error diffusion trades spatial resolution for apparent tone instead, which
|
||||
* is what makes a photo readable at 300 dpi.
|
||||
*
|
||||
* Two things matter for this to look like a face rather than noise:
|
||||
* Three things matter for this to look like a face rather than noise:
|
||||
*
|
||||
* 1. Dither at the exact pixel size the photo will occupy. Rescaling a
|
||||
* dithered image resamples the dot pattern back into greys, and the later
|
||||
@@ -20,6 +20,12 @@ import { createCanvas } from '@napi-rs/canvas';
|
||||
* produces flat mush. Normalising to the full range first gives the error
|
||||
* diffusion something to work with.
|
||||
*
|
||||
* 3. Aim for a fixed ink coverage. Thermal dots spread as the paper heats, so
|
||||
* they come out fatter on the label than they look on screen, and a
|
||||
* digitally "correct" image prints muddy. Targeting coverage also makes
|
||||
* every badge print at the same density regardless of how the visitor
|
||||
* happened to be lit.
|
||||
*
|
||||
* Text is deliberately NOT dithered anywhere — dithered glyph edges look furry
|
||||
* at this resolution. Only photographs go through here.
|
||||
*/
|
||||
@@ -100,6 +106,58 @@ function floydSteinberg(grey, width, height) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a constant to every sample, clamped to the printable range. */
|
||||
function applyShift(grey, shift) {
|
||||
if (!shift) return;
|
||||
for (let p = 0; p < grey.length; p++) {
|
||||
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fraction of dots that would burn, for a given luminance buffer. */
|
||||
function coverageOf(grey, width, height) {
|
||||
const trial = Float32Array.from(grey);
|
||||
floydSteinberg(trial, width, height);
|
||||
let black = 0;
|
||||
for (let p = 0; p < trial.length; p++) if (trial[p] < 128) black++;
|
||||
return black / trial.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the brightness offset that lands the dithered result on a given ink
|
||||
* coverage.
|
||||
*
|
||||
* Error diffusion preserves mean tone, so coverage is roughly 1 - mean/255 and
|
||||
* the first guess can be computed directly rather than searched for. Clipping
|
||||
* at the ends of the range spoils that slightly, so up to three cheap
|
||||
* correction passes follow. Each pass is one dither over a few tens of
|
||||
* thousands of samples — the whole thing runs in about 6 ms for a 24 mm photo.
|
||||
*
|
||||
* Capped at +/-120 so a very dark or very bright capture degrades into
|
||||
* something faint rather than a blank square.
|
||||
*/
|
||||
function solveShiftForCoverage(grey, width, height, target, maxPasses = 3) {
|
||||
const clamp = (v) => Math.min(120, Math.max(-120, v));
|
||||
|
||||
let mean = 0;
|
||||
for (let p = 0; p < grey.length; p++) mean += grey[p];
|
||||
mean /= grey.length;
|
||||
|
||||
let shift = clamp(255 * (1 - target) - mean);
|
||||
|
||||
for (let pass = 0; pass < maxPasses; pass++) {
|
||||
const trial = Float32Array.from(grey);
|
||||
applyShift(trial, shift);
|
||||
const actual = coverageOf(trial, width, height);
|
||||
const error = actual - target;
|
||||
if (Math.abs(error) < 0.005) break;
|
||||
// Coverage moves roughly linearly with the offset over this range.
|
||||
shift = clamp(shift + error * 255);
|
||||
}
|
||||
|
||||
return shift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dither a photo to 1-bit at a given square size.
|
||||
*
|
||||
@@ -109,12 +167,16 @@ function floydSteinberg(grey, width, height) {
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.levels=true] Stretch contrast first
|
||||
* @param {number} [options.brightness=0] -100..100, nudge before dithering.
|
||||
* Negative darkens; useful if badges
|
||||
* print washed out on old rolls.
|
||||
* Negative darkens.
|
||||
* @param {number} [options.targetCoverage=0.333]
|
||||
* Fraction of dots to burn, 0..1. The brightness needed to hit it is
|
||||
* solved for, so every photo prints at the same density however it was
|
||||
* lit. Raise it for a heavier print, lower it for a lighter one. Pass
|
||||
* null to leave density alone and print whatever the photo gives.
|
||||
* @returns {import('@napi-rs/canvas').Canvas} ready to pass to drawImage
|
||||
*/
|
||||
export function ditherPhoto(image, size, options = {}) {
|
||||
const { levels = true, brightness = 0 } = options;
|
||||
const { levels = true, brightness = 0, targetCoverage = 0.333 } = options;
|
||||
|
||||
const canvas = createCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
@@ -149,11 +211,10 @@ export function ditherPhoto(image, size, options = {}) {
|
||||
|
||||
if (levels) autoLevels(grey);
|
||||
|
||||
if (brightness !== 0) {
|
||||
const shift = (brightness / 100) * 255;
|
||||
for (let p = 0; p < grey.length; p++) {
|
||||
grey[p] = Math.min(255, Math.max(0, grey[p] + shift));
|
||||
}
|
||||
if (brightness !== 0) applyShift(grey, (brightness / 100) * 255);
|
||||
|
||||
if (targetCoverage != null) {
|
||||
applyShift(grey, solveShiftForCoverage(grey, size, size, targetCoverage));
|
||||
}
|
||||
|
||||
floydSteinberg(grey, size, size);
|
||||
|
||||
Reference in New Issue
Block a user