Public Access
PhotoDithering
This commit is contained in:
@@ -184,6 +184,26 @@ refuses a monochrome job on two-colour tape, saying *Black/Red on White paper is
|
|||||||
Change it to Monochrome media.* The roll setting controls how the job is built, not just its
|
Change it to Monochrome media.* The roll setting controls how the job is built, not just its
|
||||||
colour.
|
colour.
|
||||||
|
|
||||||
|
### Photos on the badge
|
||||||
|
|
||||||
|
A thermal printer has one bit per dot: every pixel is either burnt or not. A photo therefore has
|
||||||
|
to be reduced to pure black and white, and how that is done makes the difference between a
|
||||||
|
recognisable face and a few solid blobs.
|
||||||
|
|
||||||
|
Under **Sites → Edit → Badge printing**:
|
||||||
|
|
||||||
|
| Setting | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Error diffusion | Scatters the rounding error into neighbouring dots, so mid tones survive as a pattern. The default, and much the best for faces. |
|
||||||
|
| Hard threshold | Every pixel darker than the cut becomes solid black. Crisp for line art, ruinous for photographs. |
|
||||||
|
| Leave it to the driver | Sends greyscale and lets `brother_ql` decide. |
|
||||||
|
| Threshold | Where the cut falls. Higher is darker. |
|
||||||
|
| Contrast | Applied before the reduction. Webcam photos are flat and flatten further at one bit, so a lift of 20 to 30 usually helps. |
|
||||||
|
|
||||||
|
**Preview the photo settings** renders the most recent real visitor photo at several settings side
|
||||||
|
by side, so the choice is made by eye. The halftoning happens in the server's renderer, not in the
|
||||||
|
printer driver, so the preview and the printed label are the same image.
|
||||||
|
|
||||||
**Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer**
|
**Tell it which roll is loaded.** *Roll loaded in the printer* under **Sites → Edit → Printer**
|
||||||
must match what is physically in the machine. A two-colour job sent to a plain roll is refused
|
must match what is physically in the machine. A two-colour job sent to a plain roll is refused
|
||||||
outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
|
outright: the printer shows **Wrong Roll Type** and nothing comes out. The setting is separate
|
||||||
|
|||||||
@@ -423,3 +423,25 @@ pre.raw {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------- photo halftoning */
|
||||||
|
|
||||||
|
.photo-tuning {
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.photo-tuning input[type="range"] { width: 100%; }
|
||||||
|
.photo-tuning .modal-field span b { font-variant-numeric: tabular-nums; }
|
||||||
|
.photo-tuning .hint { margin: 10px 0; }
|
||||||
|
|
||||||
|
.photo-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -833,6 +833,24 @@ function openSiteModal(site) {
|
|||||||
</div>
|
</div>
|
||||||
<p class="hint" id="badge-warning" hidden></p>
|
<p class="hint" id="badge-warning" hidden></p>
|
||||||
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||||||
|
<div class="photo-tuning">
|
||||||
|
<label class="modal-field"><span>Photo rendering</span>
|
||||||
|
<select name="photoMode" id="photo-mode">
|
||||||
|
<option value="dither" ${site.photo.mode === 'dither' ? 'selected' : ''}>Error diffusion — best for faces</option>
|
||||||
|
<option value="threshold" ${site.photo.mode === 'threshold' ? 'selected' : ''}>Hard threshold — crisp, loses detail</option>
|
||||||
|
<option value="none" ${site.photo.mode === 'none' ? 'selected' : ''}>Leave it to the printer driver</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="modal-field"><span>Threshold <b id="photo-threshold-value">${site.photo.threshold}</b>%</span>
|
||||||
|
<input type="range" name="photoThreshold" id="photo-threshold" min="5" max="95" step="5" value="${site.photo.threshold}"></label>
|
||||||
|
<label class="modal-field"><span>Contrast <b id="photo-contrast-value">${site.photo.contrast}</b></span>
|
||||||
|
<input type="range" name="photoContrast" id="photo-contrast" min="-50" max="100" step="10" value="${site.photo.contrast}"></label>
|
||||||
|
<button type="button" class="ghost" id="photo-preview-btn">Preview the photo settings</button>
|
||||||
|
<p class="hint">The printer has one bit per dot, so a photo has to become pure black and
|
||||||
|
white. A hard threshold turns a face into solid blocks; error diffusion scatters the
|
||||||
|
rounding error into neighbouring dots and keeps the tones readable. Higher threshold means
|
||||||
|
darker. The preview shows the most recent real visitor photo.</p>
|
||||||
|
<img id="photo-preview" class="photo-preview" alt="" hidden>
|
||||||
|
</div>
|
||||||
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
||||||
<p class="hint" id="accent-note"></p>
|
<p class="hint" id="accent-note"></p>
|
||||||
<p class="hint">Two-colour printing is much slower than black alone.</p>
|
<p class="hint">Two-colour printing is much slower than black alone.</p>
|
||||||
@@ -929,6 +947,11 @@ function openSiteModal(site) {
|
|||||||
rotate: Number(data.printerRotate) || 0,
|
rotate: Number(data.printerRotate) || 0,
|
||||||
label: data.printerLabel,
|
label: data.printerLabel,
|
||||||
},
|
},
|
||||||
|
photo: {
|
||||||
|
mode: data.photoMode,
|
||||||
|
threshold: Number(data.photoThreshold),
|
||||||
|
contrast: Number(data.photoContrast),
|
||||||
|
},
|
||||||
branding: {
|
branding: {
|
||||||
brand: data.brand || null,
|
brand: data.brand || null,
|
||||||
signout: data.signout || null,
|
signout: data.signout || null,
|
||||||
@@ -1037,6 +1060,34 @@ function wireBannerEditor() {
|
|||||||
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
|
||||||
showContrast();
|
showContrast();
|
||||||
|
|
||||||
|
// Live readouts for the halftone sliders, and a preview on demand. The preview
|
||||||
|
// is not automatic: it re-renders a real photo five times and is not free.
|
||||||
|
const modeSel = $('#photo-mode');
|
||||||
|
const thr = $('#photo-threshold');
|
||||||
|
const con = $('#photo-contrast');
|
||||||
|
const refreshLabels = () => {
|
||||||
|
$('#photo-threshold-value').textContent = thr.value;
|
||||||
|
$('#photo-contrast-value').textContent = con.value;
|
||||||
|
const off = modeSel.value === 'none';
|
||||||
|
thr.disabled = off;
|
||||||
|
con.disabled = off;
|
||||||
|
};
|
||||||
|
[thr, con].forEach((el) => el?.addEventListener('input', refreshLabels));
|
||||||
|
modeSel?.addEventListener('change', refreshLabels);
|
||||||
|
refreshLabels();
|
||||||
|
|
||||||
|
$('#photo-preview-btn')?.addEventListener('click', () => {
|
||||||
|
const img = $('#photo-preview');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mode: modeSel.value,
|
||||||
|
threshold: thr.value,
|
||||||
|
contrast: con.value,
|
||||||
|
t: Date.now(),
|
||||||
|
});
|
||||||
|
img.src = `/admin/api/sites/${site.id}/photo-preview?${params}`;
|
||||||
|
img.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
// Red is only possible on the two-colour roll, so say so as the two settings change.
|
// Red is only possible on the two-colour roll, so say so as the two settings change.
|
||||||
const accentBox = $('#badge-accent');
|
const accentBox = $('#badge-accent');
|
||||||
const rollSelect = $('#modal-form [name="printerLabel"]');
|
const rollSelect = $('#modal-form [name="printerLabel"]');
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
|
printer_model TEXT NOT NULL DEFAULT 'QL-820NWB',
|
||||||
printer_rotate INTEGER NOT NULL DEFAULT 0,
|
printer_rotate INTEGER NOT NULL DEFAULT 0,
|
||||||
printer_label TEXT NOT NULL DEFAULT '62',
|
printer_label TEXT NOT NULL DEFAULT '62',
|
||||||
|
photo_mode TEXT NOT NULL DEFAULT 'dither',
|
||||||
|
photo_threshold INTEGER NOT NULL DEFAULT 50,
|
||||||
|
photo_contrast INTEGER NOT NULL DEFAULT 20,
|
||||||
banner_path TEXT,
|
banner_path TEXT,
|
||||||
banner_height INTEGER NOT NULL DEFAULT 64,
|
banner_height INTEGER NOT NULL DEFAULT 64,
|
||||||
banner_align TEXT NOT NULL DEFAULT 'left',
|
banner_align TEXT NOT NULL DEFAULT 'left',
|
||||||
@@ -188,6 +191,11 @@ addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0');
|
|||||||
// the printer refuses a two-colour job on a plain roll, so guessing the media
|
// the printer refuses a two-colour job on a plain roll, so guessing the media
|
||||||
// from a design setting means a wrong-roll error nobody can explain.
|
// from a design setting means a wrong-roll error nobody can explain.
|
||||||
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
|
addColumn('sites', 'printer_label', "TEXT NOT NULL DEFAULT '62'");
|
||||||
|
// How the photo is reduced to the printer's one bit per dot. A plain threshold
|
||||||
|
// turns a face into solid blocks; error diffusion keeps the tones readable.
|
||||||
|
addColumn('sites', 'photo_mode', "TEXT NOT NULL DEFAULT 'dither'");
|
||||||
|
addColumn('sites', 'photo_threshold', 'INTEGER NOT NULL DEFAULT 50');
|
||||||
|
addColumn('sites', 'photo_contrast', 'INTEGER NOT NULL DEFAULT 20');
|
||||||
addColumn('frequent_visitors', 'company', 'TEXT');
|
addColumn('frequent_visitors', 'company', 'TEXT');
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||||
|
|||||||
+154
-1
@@ -63,6 +63,155 @@ export function accentWillPrintRed(site) {
|
|||||||
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
|
return Boolean(site?.badge_accent) && labelFor(site) === '62red';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- photo halftoning */
|
||||||
|
|
||||||
|
export const PHOTO_MODES = {
|
||||||
|
dither: 'Error diffusion — best for faces',
|
||||||
|
threshold: 'Hard threshold — crisp, loses detail',
|
||||||
|
none: 'Leave it to the printer driver',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces a photo to the one bit per dot the printer actually has.
|
||||||
|
*
|
||||||
|
* Done here rather than left to brother_ql so the preview and the label agree,
|
||||||
|
* and because the default is a plain threshold: every pixel darker than the cut
|
||||||
|
* becomes solid black, which turns a face into a few featureless blobs. Error
|
||||||
|
* diffusion spreads the rounding error into neighbouring pixels instead, so mid
|
||||||
|
* tones survive as a pattern of dots.
|
||||||
|
*/
|
||||||
|
function halftone(ctx, x, y, size, { mode = 'dither', threshold = 50, contrast = 20 } = {}) {
|
||||||
|
if (mode === 'none') return;
|
||||||
|
|
||||||
|
const image = ctx.getImageData(x, y, size, size);
|
||||||
|
const { data, width, height } = image;
|
||||||
|
const cut = Math.max(1, Math.min(99, threshold)) * 2.55;
|
||||||
|
|
||||||
|
// Standard contrast curve, pivoting on mid grey. Webcam photos are flat and
|
||||||
|
// flatten further when reduced to two tones, so a little lift helps.
|
||||||
|
const c = Math.max(-100, Math.min(100, contrast));
|
||||||
|
const factor = (259 * (c + 255)) / (255 * (259 - c));
|
||||||
|
|
||||||
|
// Greyscale first, into a float buffer so the diffused error does not clip.
|
||||||
|
const grey = new Float32Array(width * height);
|
||||||
|
for (let i = 0; i < width * height; i += 1) {
|
||||||
|
const r = data[i * 4];
|
||||||
|
const g = data[i * 4 + 1];
|
||||||
|
const b = data[i * 4 + 2];
|
||||||
|
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
grey[i] = Math.max(0, Math.min(255, factor * (luma - 128) + 128));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let py = 0; py < height; py += 1) {
|
||||||
|
for (let px = 0; px < width; px += 1) {
|
||||||
|
const i = py * width + px;
|
||||||
|
const old = grey[i];
|
||||||
|
const next = old < cut ? 0 : 255;
|
||||||
|
grey[i] = next;
|
||||||
|
|
||||||
|
if (mode === 'dither') {
|
||||||
|
// Floyd-Steinberg: push the rounding error to pixels not yet visited.
|
||||||
|
const err = old - next;
|
||||||
|
const spread = (dx, dy, weight) => {
|
||||||
|
const nx = px + dx;
|
||||||
|
const ny = py + dy;
|
||||||
|
if (nx < 0 || nx >= width || ny >= height) return;
|
||||||
|
grey[ny * width + nx] += err * weight;
|
||||||
|
};
|
||||||
|
spread(1, 0, 7 / 16);
|
||||||
|
spread(-1, 1, 3 / 16);
|
||||||
|
spread(0, 1, 5 / 16);
|
||||||
|
spread(1, 1, 1 / 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < width * height; i += 1) {
|
||||||
|
const v = grey[i] < 128 ? 0 : 255;
|
||||||
|
data[i * 4] = v;
|
||||||
|
data[i * 4 + 1] = v;
|
||||||
|
data[i * 4 + 2] = v;
|
||||||
|
data[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
ctx.putImageData(image, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function photoSettings(site) {
|
||||||
|
return {
|
||||||
|
mode: Object.hasOwn(PHOTO_MODES, site?.photo_mode) ? site.photo_mode : 'dither',
|
||||||
|
threshold: Number.isFinite(Number(site?.photo_threshold)) ? Number(site.photo_threshold) : 50,
|
||||||
|
contrast: Number.isFinite(Number(site?.photo_contrast)) ? Number(site.photo_contrast) : 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comparison sheet: the same photo at several settings, side by side, so the
|
||||||
|
* right one can be chosen by eye rather than by guessing at numbers.
|
||||||
|
*/
|
||||||
|
export async function photoPreviewPng(photoPath, current = {}) {
|
||||||
|
const tile = 260;
|
||||||
|
const gap = 18;
|
||||||
|
const caption = 46;
|
||||||
|
|
||||||
|
const settings = photoSettings(current);
|
||||||
|
const variants = [
|
||||||
|
{ label: `Current: ${PHOTO_MODES[settings.mode].split(' —')[0]} ${settings.threshold}%, contrast ${settings.contrast}`, ...settings },
|
||||||
|
{ label: 'Dither, threshold 50, contrast 0', mode: 'dither', threshold: 50, contrast: 0 },
|
||||||
|
{ label: 'Dither, threshold 50, contrast 30', mode: 'dither', threshold: 50, contrast: 30 },
|
||||||
|
{ label: 'Dither, threshold 60, contrast 30', mode: 'dither', threshold: 60, contrast: 30 },
|
||||||
|
{ label: 'Hard threshold 50', mode: 'threshold', threshold: 50, contrast: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const canvas = createCanvas(
|
||||||
|
gap + variants.length * (tile + gap),
|
||||||
|
gap + tile + caption
|
||||||
|
);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const abs = photoAbsolutePath(photoPath);
|
||||||
|
let image = null;
|
||||||
|
if (abs) {
|
||||||
|
try {
|
||||||
|
image = await loadImage(abs);
|
||||||
|
} catch {
|
||||||
|
image = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, variant] of variants.entries()) {
|
||||||
|
const x = gap + index * (tile + gap);
|
||||||
|
if (image) {
|
||||||
|
ctx.drawImage(image, x, gap, tile, tile);
|
||||||
|
halftone(ctx, x, gap, tile, variant);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = '#f0f0f0';
|
||||||
|
ctx.fillRect(x, gap, tile, tile);
|
||||||
|
ctx.fillStyle = '#555555';
|
||||||
|
ctx.font = '14px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('no photo yet', x + tile / 2, gap + tile / 2);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#999999';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x + 0.5, gap + 0.5, tile, tile);
|
||||||
|
|
||||||
|
ctx.fillStyle = index === 0 ? '#0b4f4a' : '#222222';
|
||||||
|
ctx.font = `${index === 0 ? 'bold ' : ''}13px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
for (const [line, text] of variant.label.split(', contrast').entries()) {
|
||||||
|
ctx.fillText(
|
||||||
|
line === 0 ? text : `contrast${text}`,
|
||||||
|
x + tile / 2,
|
||||||
|
gap + tile + 20 + line * 16
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ rendering */
|
/* ------------------------------------------------------------ rendering */
|
||||||
|
|
||||||
function wrapText(ctx, text, maxWidth, maxLines) {
|
function wrapText(ctx, text, maxWidth, maxLines) {
|
||||||
@@ -122,7 +271,10 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
|||||||
if (photo) {
|
if (photo) {
|
||||||
if (portrait) {
|
if (portrait) {
|
||||||
const x = Math.round((widthDots - photoSize) / 2);
|
const x = Math.round((widthDots - photoSize) / 2);
|
||||||
if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
if (paint) {
|
||||||
|
ctx.drawImage(photo, x, cursorY, photoSize, photoSize);
|
||||||
|
halftone(ctx, x, cursorY, photoSize, photoSettings(site));
|
||||||
|
}
|
||||||
if (paint) {
|
if (paint) {
|
||||||
ctx.strokeStyle = black;
|
ctx.strokeStyle = black;
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||||
@@ -133,6 +285,7 @@ async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY
|
|||||||
const y = Math.round((heightDots - photoSize) / 2);
|
const y = Math.round((heightDots - photoSize) / 2);
|
||||||
if (paint) {
|
if (paint) {
|
||||||
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
ctx.drawImage(photo, pad, y, photoSize, photoSize);
|
||||||
|
halftone(ctx, pad, y, photoSize, photoSettings(site));
|
||||||
ctx.strokeStyle = black;
|
ctx.strokeStyle = black;
|
||||||
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
ctx.lineWidth = Math.max(2, Math.round(mm(0.3)));
|
||||||
ctx.strokeRect(pad, y, photoSize, photoSize);
|
ctx.strokeRect(pad, y, photoSize, photoSize);
|
||||||
|
|||||||
+44
-2
@@ -392,12 +392,14 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
const badge = req.body?.badge || {};
|
const badge = req.body?.badge || {};
|
||||||
const branding = req.body?.branding || {};
|
const branding = req.body?.branding || {};
|
||||||
const printerCfg = req.body?.printer || {};
|
const printerCfg = req.body?.printer || {};
|
||||||
|
const photoCfg = req.body?.photo || {};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
|
||||||
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?,
|
||||||
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?,
|
||||||
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ? WHERE id = ?`
|
printer_port = ?, printer_model = ?, printer_rotate = ?, printer_label = ?,
|
||||||
|
photo_mode = ?, photo_threshold = ?, photo_contrast = ? WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||||
@@ -435,7 +437,16 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
: site.printer_rotate,
|
: site.printer_rotate,
|
||||||
printerCfg.label !== undefined
|
printerCfg.label !== undefined
|
||||||
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
? (Object.hasOwn(printer.ROLL_TYPES, printerCfg.label) ? printerCfg.label : '62')
|
||||||
: site.printer_label
|
: site.printer_label,
|
||||||
|
photoCfg.mode !== undefined
|
||||||
|
? (Object.hasOwn(printer.PHOTO_MODES, photoCfg.mode) ? photoCfg.mode : 'dither')
|
||||||
|
: site.photo_mode,
|
||||||
|
photoCfg.threshold !== undefined
|
||||||
|
? Math.min(95, Math.max(5, Number(photoCfg.threshold) || 50))
|
||||||
|
: site.photo_threshold,
|
||||||
|
photoCfg.contrast !== undefined
|
||||||
|
? Math.min(100, Math.max(-100, Number(photoCfg.contrast) || 0))
|
||||||
|
: site.photo_contrast
|
||||||
, site.id);
|
, site.id);
|
||||||
|
|
||||||
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
|
||||||
@@ -561,6 +572,37 @@ router.post('/visits/:id/print', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The same photo at several halftone settings, to choose between by eye. */
|
||||||
|
router.get('/sites/:id/photo-preview', async (req, res) => {
|
||||||
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
|
if (!site) return res.status(404).send('Not found.');
|
||||||
|
|
||||||
|
// Whatever real photo is closest to hand: the most recent visit at this site,
|
||||||
|
// then any stored recurring visitor photo.
|
||||||
|
const recent =
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
'SELECT photo_path FROM visits WHERE site_id = ? AND photo_path IS NOT NULL ORDER BY id DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.get(site.id) ||
|
||||||
|
db.prepare('SELECT photo_path FROM frequent_visitors WHERE photo_path IS NOT NULL LIMIT 1').get();
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
photo_mode: req.query.mode || site.photo_mode,
|
||||||
|
photo_threshold: req.query.threshold || site.photo_threshold,
|
||||||
|
photo_contrast: req.query.contrast || site.photo_contrast,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const png = await printer.photoPreviewPng(recent?.photo_path || null, overrides);
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.send(png);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).send(`Could not render the preview: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/sites/:id/badge-preview', (req, res) => {
|
router.get('/sites/:id/badge-preview', (req, res) => {
|
||||||
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
|
||||||
if (!site) return res.status(404).send('Not found.');
|
if (!site) return res.status(404).send('Not found.');
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export function shapeSite(site) {
|
|||||||
rotate: site.printer_rotate || 0,
|
rotate: site.printer_rotate || 0,
|
||||||
label: site.printer_label || '62',
|
label: site.printer_label || '62',
|
||||||
},
|
},
|
||||||
|
photo: {
|
||||||
|
mode: site.photo_mode || 'dither',
|
||||||
|
threshold: site.photo_threshold ?? 50,
|
||||||
|
contrast: site.photo_contrast ?? 20,
|
||||||
|
},
|
||||||
badge: {
|
badge: {
|
||||||
enabled: Boolean(site.badge_enabled),
|
enabled: Boolean(site.badge_enabled),
|
||||||
widthMm: site.badge_width_mm,
|
widthMm: site.badge_width_mm,
|
||||||
|
|||||||
Reference in New Issue
Block a user