From c648bdf5605c3dccd889a5eeb4af44b766c94bce Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 14 Aug 2026 14:03:49 -0400 Subject: [PATCH] labels: one module knows how to draw a code, seven views stop guessing Three core pages and four plugin pages each imported qrcode and jsbarcode directly, and each carried its own answer to the same questions: what margin, what width, which error correction, how big a module must be before a scanner can read it. The answers had already drifted - margin 0 in one place and 2 in another, width 150 against 160 - and on a label that is the difference between a sticker that scans and one that does not. frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone and margin defaults, CODE128 with no printed value, and the printer-resolution arithmetic that only the Tech Tools generator had. A view passes what is specific to its own label and nothing else - MachineBadge still asks for CODE39, because the badge readers predate the shop-floor scanners and decode nothing else, and that is exactly the kind of thing a call site should say out loud. views/print/qrLogo.js is folded in rather than left as a second half-shared helper that only some of the pages reached into. The check script now fails a build that imports either library outside that module. Without it this re-forks within a month: the next label page starts by copying the nearest existing one, which is how it happened the first time. Tests cover the part no amount of looking at a screen verifies - a QR that looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch stock. --- frontend/src/utils/codes.js | 202 ++++++ frontend/src/utils/codes.spec.js | 93 +++ frontend/src/views/print/AssetLabel.vue | 7 +- frontend/src/views/print/AssetLabelBatch.vue | 8 +- frontend/src/views/print/MachineBadge.vue | 12 +- frontend/src/views/print/qrLogo.js | 62 -- .../frontend/views/PrintedPartsLabels.vue | 6 +- .../frontend/views/PrinterQRBatch.vue | 614 +++++++++--------- .../frontend/views/PrinterQRSingle.vue | 374 +++++------ .../tools/frontend/views/CodeGenerator.vue | 54 +- plugins/usb/frontend/views/USBLabelBatch.vue | 19 +- scripts/check-naming-and-style.sh | 18 + 12 files changed, 836 insertions(+), 633 deletions(-) create mode 100644 frontend/src/utils/codes.js create mode 100644 frontend/src/utils/codes.spec.js delete mode 100644 frontend/src/views/print/qrLogo.js diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js new file mode 100644 index 0000000..381cba5 --- /dev/null +++ b/frontend/src/utils/codes.js @@ -0,0 +1,202 @@ +// One place that knows how to draw a barcode or a QR code for a label. +// +// Seven views were generating codes - three in core, four in plugins - each +// importing qrcode/jsbarcode directly and each carrying its own answer to the +// same questions: what margin, what width, which error correction, how big a +// module has to be before a scanner can read it. The answers had already +// drifted (margin 0 in one place and 2 in another, width 150 against 160), and +// none of the label pages knew the printer-resolution arithmetic the Tech Tools +// generator worked out. +// +// So the knowledge lives here and the views pass what they need. Nothing about +// a specific label belongs in this file; nothing about how to render a code +// belongs anywhere else. +// +// WHY SVG, MOSTLY. A code renders to an SVG data URI rather than a canvas or a +// PNG for two reasons found the hard way on the label pages: an prints +// reliably where a live canvas or inline SVG does not, and SVG rasterises at +// the printer's resolution with hard module edges. A PNG gets downscaled to the +// label size and smears exactly the edges a scanner reads. The one exception is +// a QR with a logo composited into it, which needs a canvas to composite on. + +import QRCode from 'qrcode' +import JsBarcode from 'jsbarcode' + +import { getQrLogo } from '@/utils/siteSettings' + +// Label stock actually loaded in the printers, with the code size, padding and +// quiet zone that fit each one. Sizes are inches, because that is what the +// stock is sold as and what @page takes. +export const LABEL_PRESETS = [ + { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 }, + { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 }, + { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 }, + { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 }, + { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 }, +] + +// CODE128 is what the scanners on the floor are configured for, and a label +// carries its text separately, so the barcode never renders its own. +export const BARCODE_DEFAULTS = { + format: 'CODE128', + displayValue: false, + margin: 0, + width: 2, + height: 100, +} + +export function svgDataUri(svg) { + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) +} + +/** + * Modules across a QR for this content and error correction, quiet zone + * excluded. 0 when the content cannot be encoded at all. + */ +export function qrModuleCount(text, errorCorrection = 'M') { + if (!text) return 0 + try { + return QRCode.create(text, { errorCorrectionLevel: errorCorrection }).modules.size + } catch { + return 0 + } +} + +/** + * Whether a QR of `sizeInches` can actually be printed at `dpi`. + * + * A scanner reads modules, not inches. Below about two printer dots per module + * the edges blur into each other and the code stops scanning reliably - and a + * fractional dots-per-module means some modules are a dot wider than others, + * which is the difference between a label that always works and one that works + * on most printers. `snapped` is the nearest size that divides evenly. + */ +export function qrFit(text, { sizeInches, dpi = 203, errorCorrection = 'M' } = {}) { + const modules = qrModuleCount(text, errorCorrection) + if (!modules || !sizeInches) return null + const dotsPerModule = (sizeInches * dpi) / modules + const whole = Math.floor(dotsPerModule) + return { + modules, + dotsPerModule, + wholeDots: whole, + snapped: whole > 0 ? (modules * whole) / dpi : 0, + moduleMm: whole > 0 ? (whole / dpi) * 25.4 : 0, + // Below two dots per module the code is not reliably scannable, whatever + // it looks like on screen. + tooSmall: whole < 2, + even: whole > 0 && Math.abs((modules * whole) / dpi - sizeInches) < 0.002, + } +} + +/** + * A QR as an SVG data URI. + * + * margin 0 by default: on a label the quiet zone is blank label supplied by the + * layout, so spending the code box on white the page cannot then adjust makes + * the printed code smaller for no benefit. A caller rendering to a screen, or + * onto something with no controlled surround, should pass a margin. + */ +export async function qrSvgDataUri(text, { errorCorrection = 'M', margin = 0 } = {}) { + const svg = await QRCode.toString(text, { + type: 'svg', + errorCorrectionLevel: errorCorrection, + margin, + }) + return svgDataUri(svg) +} + +/** + * A QR as a PNG data URL. Use only where a raster is genuinely needed - a + * composited logo, or an existing page built around . + */ +export async function qrPngDataUrl(text, { width = 160, margin = 0, errorCorrection = 'M' } = {}) { + return QRCode.toDataURL(text, { width, margin, errorCorrectionLevel: errorCorrection }) +} + +/** + * Render a barcode into an existing SVG or canvas element. + * + * Kept as a thin pass-through because several pages hold an element ref and + * want JsBarcode to draw straight into it; the value here is the shared + * defaults, not the indirection. + */ +export function barcodeInto(element, text, options = {}) { + JsBarcode(element, text, { ...BARCODE_DEFAULTS, ...options }) + return element +} + +/** A barcode as an SVG data URI, for pages that print an . */ +export function barcodeSvgDataUri(text, options = {}) { + const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + barcodeInto(element, text, options) + return svgDataUri(new XMLSerializer().serializeToString(element)) +} + +// -- QR with a logo composited in the middle --------------------------------- +// +// Moved here from views/print/qrLogo.js, which core label pages and the printer +// QR pages both reached into. The overlay image is a site setting; the monogram +// below is the last-resort fallback when that setting is empty or unreachable, +// so a label still carries a mark rather than a hole. + +const FALLBACK_LOGO_SVG = `` + +let logoImage = null + +function loadLogo(url) { + if (logoImage) return Promise.resolve(logoImage) + return new Promise(resolve => { + const img = new Image() + img.onload = () => { logoImage = img; resolve(img) } + img.onerror = () => { + const fallback = new Image() + fallback.onload = () => { logoImage = fallback; resolve(fallback) } + fallback.onerror = () => resolve(null) + fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(FALLBACK_LOGO_SVG) + } + img.src = url + }) +} + +function drawLogoOverlay(canvas, logo) { + const canvasContext = canvas.getContext('2d') + const size = canvas.width + const logoSize = Math.round(size * 0.22) + const x = (size - logoSize) / 2 + const y = (size - logoSize) / 2 + + // White circle behind the mark, so it sits on the code rather than in it. + canvasContext.beginPath() + canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) + canvasContext.fillStyle = '#fff' + canvasContext.fill() + + if (logo) { + canvasContext.drawImage(logo, x, y, logoSize, logoSize) + } +} + +/** + * A QR with the configured logo composited in the centre, as a PNG data URL. + * + * Error correction H, because up to 30% of the code can be obscured and still + * decode - which is what makes covering the middle of it survivable at all. + * Empty qr_logo setting means no overlay. Returns '' on failure: a label with + * no code is better than a page that throws while printing a batch. + */ +export async function renderQrDataUrl(url, { width = 144, margin = 0 } = {}) { + const qrLogoUrl = await getQrLogo() + const canvas = document.createElement('canvas') + try { + await QRCode.toCanvas(canvas, url, { width, margin, errorCorrectionLevel: 'H' }) + if (qrLogoUrl) { + const logo = await loadLogo(qrLogoUrl) + drawLogoOverlay(canvas, logo) + } + return canvas.toDataURL('image/png') + } catch (err) { + console.error('QR error:', err) + return '' + } +} diff --git a/frontend/src/utils/codes.spec.js b/frontend/src/utils/codes.spec.js new file mode 100644 index 0000000..cf21818 --- /dev/null +++ b/frontend/src/utils/codes.spec.js @@ -0,0 +1,93 @@ +// The arithmetic in codes.js decides whether a printed label scans, and it is +// the part no amount of looking at a screen will verify - a QR that is fine at +// 96 dpi on a monitor can be unreadable at 203 dpi on 0.5in stock. + +import { describe, it, expect } from 'vitest' + +import { + LABEL_PRESETS, BARCODE_DEFAULTS, qrModuleCount, qrFit, svgDataUri, +} from './codes' + +describe('qrModuleCount', () => { + it('grows with the content', () => { + const short = qrModuleCount('AP-01') + const long = qrModuleCount('AP-01/'.repeat(20)) + expect(short).toBeGreaterThan(0) + expect(long).toBeGreaterThan(short) + }) + + it('grows with error correction, because the redundancy is stored in modules', () => { + expect(qrModuleCount('WKSTN0042', 'H')).toBeGreaterThanOrEqual( + qrModuleCount('WKSTN0042', 'L')) + }) + + it('returns 0 rather than throwing on empty content', () => { + expect(qrModuleCount('')).toBe(0) + expect(qrModuleCount(null)).toBe(0) + }) +}) + +describe('qrFit', () => { + it('calls a code too small when a module gets under two printer dots', () => { + // A long payload squeezed onto the 1.00 x 0.50in stock: the case the Tech + // Tools generator exists to warn about before 300 labels are printed. + const fit = qrFit('https://shopdb.example.net/assets/1234?from=label&sig=abcdef123456', { + sizeInches: 0.4286, dpi: 203, errorCorrection: 'H', + }) + expect(fit.tooSmall).toBe(true) + expect(fit.dotsPerModule).toBeLessThan(2) + }) + + it('reports a whole number of dots per module as even', () => { + const modules = qrModuleCount('WKSTN0042', 'M') + // Choose the size that divides exactly at 4 dots per module. + const fit = qrFit('WKSTN0042', { + sizeInches: (modules * 4) / 203, dpi: 203, errorCorrection: 'M', + }) + expect(fit.wholeDots).toBe(4) + expect(fit.even).toBe(true) + expect(fit.snapped).toBeCloseTo(fit.snapped, 5) + }) + + it('offers the nearest evenly-dividing size when the chosen one is uneven', () => { + const fit = qrFit('WKSTN0042', { sizeInches: 0.9, dpi: 203, errorCorrection: 'M' }) + expect(fit.even).toBe(false) + expect(fit.snapped).toBeLessThanOrEqual(0.9) + expect(fit.snapped * 203 / fit.modules).toBeCloseTo(fit.wholeDots, 6) + }) + + it('is null when there is nothing to measure', () => { + expect(qrFit('', { sizeInches: 1 })).toBeNull() + expect(qrFit('WKSTN0042', {})).toBeNull() + }) +}) + +describe('shared defaults', () => { + it('barcodes are CODE128 with no margin and no printed text', () => { + // The label draws its own caption, and the quiet zone is blank label. Both + // had drifted per-view before this module existed. + expect(BARCODE_DEFAULTS.format).toBe('CODE128') + expect(BARCODE_DEFAULTS.margin).toBe(0) + expect(BARCODE_DEFAULTS.displayValue).toBe(false) + }) + + it('every label preset is a complete, usable stock definition', () => { + expect(LABEL_PRESETS.length).toBeGreaterThan(0) + for (const preset of LABEL_PRESETS) { + expect(preset.id).toBeTruthy() + expect(preset.labelwidth).toBeGreaterThan(0) + expect(preset.labelheight).toBeGreaterThan(0) + // A code has to fit inside its label with its quiet zone on both sides. + expect(preset.codesize + 2 * preset.quiet).toBeLessThanOrEqual(preset.labelwidth) + } + }) +}) + +describe('svgDataUri', () => { + it('escapes markup so the URI survives being put in a src attribute', () => { + const uri = svgDataUri('') + expect(uri.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true) + expect(uri).not.toContain('<') + expect(uri).not.toContain('"') + }) +}) diff --git a/frontend/src/views/print/AssetLabel.vue b/frontend/src/views/print/AssetLabel.vue index 14604f9..b0767ae 100644 --- a/frontend/src/views/print/AssetLabel.vue +++ b/frontend/src/views/print/AssetLabel.vue @@ -128,8 +128,7 @@ - - + + + + + diff --git a/plugins/printers/frontend/views/PrinterQRSingle.vue b/plugins/printers/frontend/views/PrinterQRSingle.vue index c41c91a..b5b3505 100644 --- a/plugins/printers/frontend/views/PrinterQRSingle.vue +++ b/plugins/printers/frontend/views/PrinterQRSingle.vue @@ -1,187 +1,187 @@ - - - - - + + + + + diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 3b15fca..6be1b83 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -163,20 +163,14 @@