Files
shopdb-flask/plugins/tools/frontend/views/CodeGenerator.vue
cproudlock c648bdf560 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.
2026-08-14 14:03:49 -04:00

651 lines
20 KiB
Vue

<template>
<div>
<div class="no-print">
<div class="controls">
<div class="controls-head">
<h3>Barcode / QR Generator</h3>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<!-- What to encode -->
<div class="control-row">
<label>
Source
<select v-model="source">
<option value="single">One code</option>
<option value="csv">CSV list</option>
</select>
</label>
<label>
Code type
<select v-model="codetype">
<option value="qr">QR</option>
<option value="barcode">CODE128</option>
</select>
</label>
<label v-if="codetype === 'qr'">
Error correction
<select v-model="errorcorrection">
<option value="L">L - smallest code</option>
<option value="M">M - standard</option>
<option value="Q">Q</option>
<option value="H">H - most robust</option>
</select>
</label>
</div>
<div v-if="source === 'single'" class="control-row">
<label class="grow">
Content (text or URL)
<textarea v-model="singleContent" rows="2"
placeholder="https://example.com or any text"></textarea>
</label>
<label class="grow">
Label (optional)
<input v-model="singleLabel" type="text" placeholder="Printed under or beside the code" />
</label>
<label>
Copies
<input v-model.number="singleCopies" type="number" min="1" max="1000" />
</label>
</div>
<div v-else class="csv-block">
<div class="control-row">
<label>
CSV file
<input type="file" accept=".csv,text/csv" @change="onCsvFile" />
</label>
<button class="btn btn-secondary" @click="downloadTemplate">Download template</button>
</div>
<label class="grow">
...or paste rows here
<textarea v-model="csvText" rows="5"
placeholder="content,label,copies&#10;https://example.com,Front desk,1&#10;WJ-LF-0001,Line 1,2"></textarea>
</label>
<p class="control-note" v-if="csvError">{{ csvError }}</p>
<p class="control-hint">
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code>.
A header row is optional; without one the order is content, label, copies.
</p>
</div>
<!-- Media -->
<h4 class="section-heading">Label stock</h4>
<div class="control-row">
<label>
Preset
<select v-model="preset" @change="applyPreset">
<option value="">Custom</option>
<option v-for="option in PRESETS" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</label>
<label>Label W (in)<input v-model.number="labelwidth" type="number" step="0.01" min="0.25" /></label>
<label>Label H (in)<input v-model.number="labelheight" type="number" step="0.01" min="0.25" /></label>
<label>Padding (in)<input v-model.number="padding" type="number" step="0.005" min="0" /></label>
<label>
Layout
<select v-model="layout">
<option value="side">Code left, label right</option>
<option value="stack">Code above label</option>
<option value="codeonly">Code only</option>
</select>
</label>
</div>
<div class="control-row">
<label v-if="codetype === 'qr'">Code size (in)<input v-model.number="codesize" type="number" step="0.0001" min="0.1" /></label>
<label v-else>Bar height (in)<input v-model.number="barheight" type="number" step="0.01" min="0.1" /></label>
<label>Quiet zone (in)<input v-model.number="quiet" type="number" step="0.005" min="0" /></label>
<label>Label font (pt)<input v-model.number="labelfont" type="number" step="0.5" min="3" /></label>
<label>Nudge X (in)<input v-model.number="nudgex" type="number" step="0.01" /></label>
<label>Nudge Y (in)<input v-model.number="nudgey" type="number" step="0.01" /></label>
</div>
<!-- The dot-size trap: a thermal head cannot render a fraction of a dot,
so a code sized off the dot grid gets uneven modules and scans
badly. This says what the current size actually lands on. -->
<div class="control-row">
<label>
Printer DPI
<select v-model.number="dpi">
<option :value="203">203</option>
<option :value="300">300</option>
<option :value="600">600</option>
</select>
</label>
<p v-if="codetype === 'qr' && fitAdvice" class="control-hint fit-advice">
{{ fitAdvice }}
</p>
</div>
<div class="control-row">
<button class="btn btn-primary" :disabled="!labels.length" @click="printAll">
Print {{ labels.length }} label{{ labels.length === 1 ? '' : 's' }}
</button>
<button class="btn btn-secondary" :disabled="!labels.length" @click="printTest">
Print 1 test label
</button>
<button class="btn btn-secondary" @click="resetSettings">Reset sizing</button>
</div>
<p v-if="truncated" class="control-note">
Showing the first {{ MAX_LABELS }} rows. {{ truncated }} more were left out
- split the CSV and print it in batches.
</p>
<p v-if="overlong.length" class="control-note">
{{ overlong.length }} row(s) could not be encoded as CODE128 or were empty:
{{ overlong.slice(0, 3).join(', ') }}{{ overlong.length > 3 ? ' ...' : '' }}
</p>
</div>
</div>
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet">
<div
v-for="(label, index) in visibleLabels"
:key="index"
class="label"
:class="['layout-' + layout, 'type-' + codetype]"
>
<div class="codebox">
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
</div>
<div v-if="layout !== 'codeonly' && label.label" class="label-text">
{{ label.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
from '@/utils/codes'
// Rendering thousands of codes locks the tab, so stop at a number a person
// would actually feed a label printer in one run and SAY what was dropped.
const MAX_LABELS = 500
const PRESETS = LABEL_PRESETS
const source = ref('single')
const codetype = ref('qr')
const errorcorrection = ref('M')
const singleContent = ref('')
const singleLabel = ref('')
const singleCopies = ref(1)
const csvText = ref('')
const csvError = ref('')
const preset = ref('zebra1x05')
const labelwidth = ref(1.0)
const labelheight = ref(0.5)
const padding = ref(0)
const quiet = ref(0.035)
const codesize = ref(0.4286)
const barheight = ref(0.3)
const labelfont = ref(7)
const layout = ref('side')
const nudgex = ref(0)
const nudgey = ref(0)
const dpi = ref(203)
const images = ref([])
const testMode = ref(false)
const overlong = ref([])
const SETTING_KEYS = [
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
'quiet', 'codesize', 'barheight', 'labelfont', 'layout', 'nudgex', 'nudgey', 'dpi',
]
const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
quiet, codesize, barheight, labelfont, layout, nudgex, nudgey, dpi,
}
// --- rows -------------------------------------------------------------------
// Split one CSV line, honoring quoted fields and "" escapes.
function splitCsvLine(line) {
const fields = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (inQuotes) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i++ }
else if (char === '"') { inQuotes = false }
else { current += char }
} else if (char === '"') {
inQuotes = true
} else if (char === ',') {
fields.push(current); current = ''
} else {
current += char
}
}
fields.push(current)
return fields.map(field => field.trim())
}
const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr']
const LABEL_HEADERS = ['label', 'name', 'caption', 'description']
const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count']
function parseCsv(text) {
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '')
if (!lines.length) return []
let columns = { content: 0, label: 1, copies: 2 }
let start = 0
const first = splitCsvLine(lines[0]).map(field => field.toLowerCase())
if (first.some(field => CONTENT_HEADERS.includes(field))) {
// Named header: map by name so column order does not matter.
const indexOf = names => first.findIndex(field => names.includes(field))
columns = {
content: indexOf(CONTENT_HEADERS),
label: indexOf(LABEL_HEADERS),
copies: indexOf(COPIES_HEADERS),
}
start = 1
}
const rows = []
for (let i = start; i < lines.length; i++) {
const fields = splitCsvLine(lines[i])
const content = columns.content >= 0 ? (fields[columns.content] || '') : ''
if (!content) continue
const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1
rows.push({
content,
label: columns.label >= 0 ? (fields[columns.label] || '') : '',
copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, 1000) : 1,
})
}
return rows
}
// Every row expanded by its copies count, capped.
const allLabels = computed(() => {
let rows = []
if (source.value === 'single') {
const content = singleContent.value.trim()
if (content) {
const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0
? singleCopies.value : 1
rows = [{ content, label: singleLabel.value.trim(), copies }]
}
} else {
try {
csvError.value = ''
rows = parseCsv(csvText.value)
if (csvText.value.trim() && !rows.length) {
csvError.value = 'No usable rows found. Every row needs a content value.'
}
} catch (error) {
csvError.value = 'Could not read that CSV: ' + error.message
rows = []
}
}
const expanded = []
for (const row of rows) {
for (let i = 0; i < row.copies; i++) {
expanded.push({ content: row.content, label: row.label })
}
}
return expanded
})
const truncated = computed(() =>
Math.max(0, allLabels.value.length - MAX_LABELS))
const labels = computed(() => allLabels.value.slice(0, MAX_LABELS))
// Printing one test label prints the first only; the preview follows so what
// you see is what comes out.
const visibleLabels = computed(() =>
testMode.value ? labels.value.slice(0, 1) : labels.value)
// --- fit advice -------------------------------------------------------------
// Modules across the QR at the current content and error correction, quiet
// zone excluded (the blank label supplies that).
const qrModules = computed(() => {
const content = labels.value[0]?.content
if (!content || codetype.value !== 'qr') return 0
return qrModuleCount(content, errorcorrection.value)
})
const fitAdvice = computed(() => {
const fit = qrFit(labels.value[0]?.content, {
sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
})
if (!fit || codetype.value !== 'qr') return ''
const { modules, dotsPerModule, wholeDots: whole, snapped, moduleMm } = fit
if (fit.tooSmall) {
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each - too small to scan `
+ `reliably. Shorten the content, drop error correction, or use bigger stock.`
}
if (fit.even) {
return `${modules} modules at exactly ${whole} dots each (${moduleMm.toFixed(3)} mm). Good.`
}
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each. Uneven - `
+ `use ${snapped.toFixed(4)} in for a whole ${whole} dots per module.`
})
// --- rendering --------------------------------------------------------------
// Each code renders to an SVG data URI, for two reasons. An <img> prints
// reliably where a live canvas or inline SVG does not (same finding the asset
// label pages are built on), and SVG rasterizes at the printer's resolution
// with hard module edges - a PNG would be downscaled to the label size and
// smear the very edges a scanner reads.
//
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
// so none of the code box is spent on white we cannot then adjust.
async function renderOne(row) {
const text = row.content
if (!text) return ''
if (codetype.value === 'qr') {
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
}
return barcodeSvgDataUri(text)
}
let renderToken = 0
async function renderAll() {
const token = ++renderToken
const failed = []
const next = []
for (const row of labels.value) {
try {
next.push(await renderOne(row))
} catch {
// CODE128 rejects some characters; an unencodable row must be named,
// not silently dropped to a blank sticker.
next.push('')
failed.push(row.content.slice(0, 20))
}
if (token !== renderToken) return
}
images.value = next
overlong.value = failed
}
// --- media vars -------------------------------------------------------------
// @page cannot read a scoped style, so the sizing lives in custom properties on
// the root element. Chromium honors var() in @page size.
function applyVars() {
const root = document.documentElement
root.style.setProperty('--tool-label-w', labelwidth.value + 'in')
root.style.setProperty('--tool-label-h', labelheight.value + 'in')
root.style.setProperty('--tool-pad', padding.value + 'in')
root.style.setProperty('--tool-code', codesize.value + 'in')
root.style.setProperty('--tool-barh', barheight.value + 'in')
// Both symbologies need clear space: a QR wants a quiet zone around it, and
// CODE128 wants one at each end (10x the narrow bar). It doubles as the gap
// between the code and the label text, so zeroing it for barcodes let the
// bars run into the caption.
root.style.setProperty('--tool-quiet', quiet.value + 'in')
root.style.setProperty('--tool-font', labelfont.value + 'pt')
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
}
function clearVars() {
const root = document.documentElement
for (const name of ['--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code',
'--tool-barh', '--tool-quiet', '--tool-font', '--tool-nudge-x', '--tool-nudge-y']) {
root.style.removeProperty(name)
}
}
function applyPreset() {
const chosen = PRESETS.find(option => option.id === preset.value)
if (!chosen) return
labelwidth.value = chosen.labelwidth
labelheight.value = chosen.labelheight
codesize.value = chosen.codesize
padding.value = chosen.padding
quiet.value = chosen.quiet
labelfont.value = chosen.labelfont
}
function resetSettings() {
preset.value = 'zebra1x05'
applyPreset()
layout.value = 'side'
barheight.value = 0.3
nudgex.value = 0
nudgey.value = 0
}
// --- csv helpers ------------------------------------------------------------
function onCsvFile(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { csvText.value = String(reader.result || '') }
reader.onerror = () => { csvError.value = 'Could not read that file.' }
reader.readAsText(file)
}
function downloadTemplate() {
const csv = [
'content,label,copies',
'https://example.com/asset/1,Bay 12 press,1',
'WJ-LF-0001,Line 1 fixture,2',
].join('\n')
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.download = 'code_labels_template.csv'
link.click()
URL.revokeObjectURL(link.href)
}
// --- printing ---------------------------------------------------------------
function printAll() {
window.print()
}
async function printTest() {
testMode.value = true
await nextTick()
window.print()
testMode.value = false
}
// --- lifecycle --------------------------------------------------------------
function saveSettings() {
const state = {}
for (const key of SETTING_KEYS) state[key] = settingRefs[key].value
try { localStorage.setItem('toolsCodeGenerator', JSON.stringify(state)) } catch { /* private mode */ }
}
onMounted(() => {
try {
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
for (const key of SETTING_KEYS) {
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
}
} catch { /* ignore a corrupt entry */ }
applyVars()
renderAll()
})
onBeforeUnmount(clearVars)
watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
applyVars()
saveSettings()
})
watch([labels, codetype, errorcorrection], renderAll)
</script>
<style scoped>
/* One label per page. Chromium reads var() here; the print dialog must be set
to Margins = None and Scale = 100%. */
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
.no-print { padding: 20px; }
.controls {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
max-width: 66rem;
}
.controls-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.controls-head h3 { margin: 0; }
.section-heading {
margin: 1.25rem 0 0.5rem;
font-size: 0.9rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.control-row {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
margin-bottom: 12px;
}
.control-row label {
display: flex;
flex-direction: column;
font-size: 0.8125rem;
gap: 4px;
color: var(--text-light);
}
.control-row label.grow { flex: 1 1 18rem; }
.control-row input,
.control-row select,
.control-row textarea,
.csv-block input,
.csv-block textarea {
padding: 6px;
font-size: 0.875rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-family: inherit;
}
.control-row input[type="number"] { width: 7rem; }
.csv-block label {
display: flex;
flex-direction: column;
font-size: 0.8125rem;
gap: 4px;
color: var(--text-light);
}
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 8px 0 0; }
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 0; }
.fit-advice { flex: 1 1 22rem; align-self: center; }
.sheet {
padding: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.label {
box-sizing: border-box;
width: var(--tool-label-w);
height: var(--tool-label-h);
padding: var(--tool-pad);
background: #fff;
color: #000;
overflow: hidden;
display: flex;
align-items: center;
position: relative;
left: var(--tool-nudge-x);
top: var(--tool-nudge-y);
outline: 1px dashed var(--border);
}
.label.layout-stack { flex-direction: column; justify-content: center; }
.label.layout-codeonly { justify-content: center; }
.codebox {
flex: 0 0 auto;
margin: var(--tool-quiet);
width: var(--tool-code);
height: var(--tool-code);
display: flex;
align-items: center;
justify-content: center;
}
/* A QR is square, so its box is code-size on a side. A CODE128 is wide and
short: it takes the width left over on the label and only its own bar
height, or the whole width when nothing shares the label with it. */
.label.type-barcode .codebox {
flex: 1 1 auto;
width: auto;
min-width: 0;
height: var(--tool-barh);
}
.code-img {
width: 100%;
height: 100%;
object-fit: contain;
}
.label-text {
flex: 1 1 auto;
min-width: 0;
text-align: center;
font-family: "Arial Narrow", Arial, Helvetica, sans-serif;
font-size: var(--tool-font);
font-weight: 700;
line-height: 1.05;
overflow-wrap: anywhere;
}
@media print {
.no-print { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; }
.label {
outline: none;
break-after: page;
page-break-after: always;
}
.label:last-of-type { break-after: auto; page-break-after: auto; }
}
</style>