diff --git a/scripts/dither-badge-photos.mjs b/scripts/dither-badge-photos.mjs new file mode 100644 index 0000000..a062b1e --- /dev/null +++ b/scripts/dither-badge-photos.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * One-shot patch: dither visitor photos before they go on the badge canvas. + * + * node scripts/dither-badge-photos.mjs # patch + * node scripts/dither-badge-photos.mjs --check # report only + * + * A thermal head has no grey. Drawn straight, a photo gets thresholded by the + * plane conversion and most of a face lands on one side of that threshold, so + * it prints as a solid black mass. This inserts a Floyd-Steinberg pass at + * exactly the size the photo will occupy. + * + * Idempotent, writes printer.js.bak, refuses to run if the anchor is missing. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const target = path.resolve(here, '..', 'src', 'printer.js'); +const checkOnly = process.argv.includes('--check'); + +function fail(message) { + console.error(`\n FAILED: ${message}\n`); + console.error(' Nothing was changed.\n'); + process.exit(1); +} + +if (!fs.existsSync(target)) fail(`${target} does not exist. Run this from the repo root.`); + +let source = fs.readFileSync(target, 'utf8'); +const original = source; + +if (source.includes('ditherPhoto(')) { + console.log('\n Already patched — printer.js dithers photos. Nothing to do.\n'); + process.exit(0); +} + +const changes = []; + +/* -- 1. Import ---------------------------------------------------- */ + +const importAnchor = "import { photoAbsolutePath } from './photos.js';"; +if (!source.includes(importAnchor)) fail('could not find the photos.js import'); + +source = source.replace( + importAnchor, + `${importAnchor}\nimport { ditherPhoto } from './printing/dither.js';` +); +changes.push('added the ditherPhoto import'); + +/* -- 2. Dither once, at final size, before either draw site ------- */ + +// Matches the existing line regardless of the exact multipliers, so tweaking +// the photo size later doesn't break this patch. +const sizeAnchor = /const photoSize = photo \? Math\.round\([^;]+\) : 0;/; +const match = source.match(sizeAnchor); +if (!match) fail('could not find the photoSize calculation in drawBadge'); + +const insertion = `${match[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); + }`; + +source = source.replace(sizeAnchor, insertion); +changes.push('drawBadge dithers the photo at its final size'); + +/* -- 3. Sanity check ---------------------------------------------- */ + +if (!/let photo = null;/.test(source)) { + fail('photo is not declared with let — it cannot be reassigned. Patch by hand.'); +} + +console.log('\n Changes to src/printer.js:'); +for (const c of changes) console.log(` - ${c}`); + +if (checkOnly) { + console.log('\n --check given, nothing written.\n'); + process.exit(0); +} + +if (source === original) fail('nothing actually changed'); + +fs.writeFileSync(`${target}.bak`, original); +fs.writeFileSync(target, source); + +console.log(`\n Wrote ${target}`); +console.log(` Backup at ${target}.bak`); +console.log('\n Next: docker compose build && docker compose up -d\n');