Give a label its own gap, its own position, and a back side
Some checks failed
CI / backend (push) Failing after 7m10s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled

Three things the code generator could not do, each of which had someone
working around it with a control that meant something else.

The gap between a code and its caption was the quiet zone. That is the
clear space a scanner needs around the code, so closing up a caption meant
shaving the thing that makes the code readable, and the only way to get a
tight label to scan was to push the caption further away than it looked
like it should be. Quiet zone now means the code's own margin and nothing
else; gap means the distance to the caption, and adds to the quiet zone
rather than eating into it. Each stock preset carries the gap that suits it.

Position had one pair of controls and they moved the label box on the
stock. That is the fix for a printer whose origin is off, and it is not the
fix for a code sitting in the wrong place on a label - but it was the only
lever there, so it got used for both. The label div is now the media and
holds still; everything printed sits in a content wrapper that Align,
Vertical and Content X/Y move within it. The old pair kept its behaviour
and is labelled Media X/Y.

A badge card has two sides and the generator only ever emitted one page per
card. The back can now carry the same code again, a fixed block of text,
its own per-row content, or nothing. Page order is a choice because the two
kinds of printer want opposite things: interleaved for a duplex card
printer, which takes pages two at a time, and grouped for a single-sided
one whose stack gets flipped and reloaded. A row that leaves its back
column empty still gets a blank back page - a page count that varies per
row shifts every later back onto the wrong front, which prints a box of
perfectly readable cards that are all wrong.

CSV parsing and page building moved to plugins/tools/frontend/labelPages.js
(committed in 875fde9), where the column contract and both page orders are
covered by tests rather than by looking at the preview.
This commit is contained in:
cproudlock
2026-08-21 09:08:37 -04:00
parent 875fde9f48
commit 39ede87d55
3 changed files with 312 additions and 145 deletions

View File

@@ -65,8 +65,10 @@
</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.
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code><span
v-if="backenabled && backsource === 'column'">, <code>back</code>, <code>backlabel</code></span>.
A header row is optional; without one the order is content, label, copies<span
v-if="backenabled && backsource === 'column'">, back, backlabel</span>.
</p>
</div>
@@ -99,11 +101,16 @@
<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>Code-label gap (in)<input v-model.number="gap" 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>
<p class="control-hint">
The quiet zone is the clear space the SCANNER needs and it stays wrapped
around the code. The gap is only the space between the code and its
caption, and it adds to the quiet zone rather than eating into it.
</p>
<!-- 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. -->
@@ -121,18 +128,107 @@
</p>
</div>
<!-- Position -->
<h4 class="section-heading">Position</h4>
<div class="control-row">
<button class="btn btn-primary" :disabled="!labels.length" @click="printAll">
Print {{ labels.length }} label{{ labels.length === 1 ? '' : 's' }}
<label>
Align
<select v-model="align">
<option value="flex-start">Left</option>
<option value="center">Centre</option>
<option value="flex-end">Right</option>
</select>
</label>
<label>
Vertical
<select v-model="valign">
<option value="flex-start">Top</option>
<option value="center">Middle</option>
<option value="flex-end">Bottom</option>
</select>
</label>
<label>Content X (in)<input v-model.number="contentx" type="number" step="0.01" /></label>
<label>Content Y (in)<input v-model.number="contenty" type="number" step="0.01" /></label>
<label>Media X (in)<input v-model.number="nudgex" type="number" step="0.01" /></label>
<label>Media Y (in)<input v-model.number="nudgey" type="number" step="0.01" /></label>
</div>
<p class="control-hint">
Content moves the code and caption INSIDE the label and leaves the label
where it is. Media moves the whole printed area on the stock - that one
is for a printer whose origin is off, not for composing a label.
</p>
<!-- Back side -->
<h4 class="section-heading">Back side</h4>
<div class="control-row">
<label class="check">
<input type="checkbox" v-model="backenabled" />
Print a back side
</label>
<template v-if="backenabled">
<label>
Back carries
<select v-model="backsource">
<option value="text">The same fixed text</option>
<option value="code">The same code again</option>
<option value="column">Its own content (per row)</option>
<option value="blank">Nothing - blank back</option>
</select>
</label>
<label>
Back layout
<select v-model="backlayout">
<option value="side">Code left, label right</option>
<option value="stack">Code above label</option>
<option value="codeonly">Code only</option>
<option value="textonly">Text only</option>
</select>
</label>
<label>
Page order
<select v-model="backorder">
<option value="interleave">Front, back, front, back (duplex printer)</option>
<option value="grouped">All fronts, then all backs (flip and reload)</option>
</select>
</label>
</template>
</div>
<div v-if="backenabled" class="control-row">
<label class="grow" v-if="backsource === 'text' || backsource === 'column'">
Back text
<textarea v-model="backtext" rows="2"
placeholder="Property of GE Aerospace - return to IT"></textarea>
</label>
<label class="grow" v-if="source === 'single' && backsource === 'column'">
Back content (encoded on the back)
<input v-model="singleBack" type="text" placeholder="Text or URL for the reverse" />
</label>
</div>
<p v-if="backenabled" class="control-hint">
<strong>Duplex printer:</strong> keep page order interleaved and set the
driver to double-sided, flip on SHORT edge for portrait card stock -
long edge prints the back upside down.
<strong>Single-sided printer:</strong> choose grouped, print the run,
flip the whole stack keeping its order, and print the second half.
Print one card both ways before committing the box.
</p>
<div class="control-row">
<button class="btn btn-primary" :disabled="!pages.length" @click="printAll">
Print {{ cardCount }} label{{ cardCount === 1 ? '' : 's' }}
<span v-if="backenabled">({{ pages.length }} pages)</span>
</button>
<button class="btn btn-secondary" :disabled="!labels.length" @click="printTest">
Print 1 test label
<button class="btn btn-secondary" :disabled="!pages.length" @click="printTest">
Print 1 test {{ backenabled ? 'card (both sides)' : '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
Showing the first {{ MAX_LABELS }} labels. {{ truncated }} more were left out
- split the CSV and print it in batches.
</p>
<p v-if="overlong.length" class="control-note">
@@ -145,17 +241,20 @@
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet">
<div
v-for="(label, index) in visibleLabels"
v-for="(page, index) in visiblePages"
:key="index"
class="label"
:class="['layout-' + layout, 'type-' + codetype]"
:class="['layout-' + layoutFor(page), 'type-' + codetype, 'side-' + page.side]"
>
<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 class="content">
<div v-if="layoutFor(page) !== 'textonly'" class="codebox">
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
</div>
<div v-if="layoutFor(page) !== 'codeonly' && page.label" class="label-text">
{{ page.label }}
</div>
</div>
<span v-if="backenabled" class="side-flag">{{ page.side }}</span>
</div>
</div>
</div>
@@ -165,10 +264,7 @@
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
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
const PRESETS = LABEL_PRESETS
@@ -179,6 +275,7 @@ const errorcorrection = ref('M')
const singleContent = ref('')
const singleLabel = ref('')
const singleCopies = ref(1)
const singleBack = ref('')
const csvText = ref('')
const csvError = ref('')
@@ -188,143 +285,112 @@ const labelwidth = ref(1.0)
const labelheight = ref(0.5)
const padding = ref(0)
const quiet = ref(0.035)
const gap = ref(0.03)
const codesize = ref(0.4286)
const barheight = ref(0.3)
const labelfont = ref(7)
const layout = ref('side')
const align = ref('center')
const valign = ref('center')
const contentx = ref(0)
const contenty = ref(0)
const nudgex = ref(0)
const nudgey = ref(0)
const dpi = ref(203)
const backenabled = ref(false)
const backsource = ref('text')
const backtext = ref('')
const backlayout = ref('textonly')
const backorder = ref('interleave')
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',
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
'backenabled', 'backsource', 'backtext', 'backlayout', 'backorder',
]
const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
quiet, codesize, barheight, labelfont, layout, nudgex, nudgey, dpi,
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
contentx, contenty, nudgex, nudgey, dpi,
backenabled, backsource, backtext, backlayout, backorder,
}
// --- 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 = []
// The rows before copies are expanded. CSV parsing and page building both live
// in ../labelPages.js, which is where they can be tested.
const rows = computed(() => {
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 = []
}
if (!content) return []
const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0
? singleCopies.value : 1
return [{
content,
label: singleLabel.value.trim(),
copies,
back: singleBack.value.trim(),
backlabel: '',
}]
}
const expanded = []
for (const row of rows) {
for (let i = 0; i < row.copies; i++) {
expanded.push({ content: row.content, label: row.label })
try {
csvError.value = ''
const parsed = parseCsv(csvText.value)
if (csvText.value.trim() && !parsed.length) {
csvError.value = 'No usable rows found. Every row needs a content value.'
}
return parsed
} catch (error) {
csvError.value = 'Could not read that CSV: ' + error.message
return []
}
return expanded
})
const truncated = computed(() =>
Math.max(0, allLabels.value.length - MAX_LABELS))
const built = computed(() => buildPages(rows.value, {
back: {
enabled: backenabled.value,
source: backsource.value,
text: backtext.value.trim(),
order: backorder.value,
},
max: MAX_LABELS,
}))
const labels = computed(() => allLabels.value.slice(0, MAX_LABELS))
const pages = computed(() => built.value.pages)
const cardCount = computed(() => built.value.fronts.length)
const truncated = computed(() => built.value.dropped)
// 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)
// Printing one test label prints the first card only - both of its sides when
// there is a back, because a back that lands upside down is the whole reason
// to print one first.
const visiblePages = computed(() => {
if (!testMode.value) return pages.value
const { fronts, backs } = built.value
return [fronts[0], backenabled.value ? backs[0] : null].filter(Boolean)
})
function layoutFor(page) {
return page.side === 'back' ? backlayout.value : layout.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
const content = built.value.fronts[0]?.content
if (!content || codetype.value !== 'qr') return 0
return qrModuleCount(content, errorcorrection.value)
})
const fitAdvice = computed(() => {
const fit = qrFit(labels.value[0]?.content, {
const fit = qrFit(built.value.fronts[0]?.content, {
sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
})
if (!fit || codetype.value !== 'qr') return ''
@@ -364,14 +430,14 @@ async function renderAll() {
const token = ++renderToken
const failed = []
const next = []
for (const row of labels.value) {
for (const page of pages.value) {
try {
next.push(await renderOne(row))
next.push(await renderOne(page))
} 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))
failed.push(page.content.slice(0, 20))
}
if (token !== renderToken) return
}
@@ -383,6 +449,12 @@ async function renderAll() {
// @page cannot read a scoped style, so the sizing lives in custom properties on
// the root element. Chromium honors var() in @page size.
const VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
]
function applyVars() {
const root = document.documentElement
root.style.setProperty('--tool-label-w', labelwidth.value + 'in')
@@ -391,21 +463,26 @@ function applyVars() {
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.
// CODE128 wants one at each end (10x the narrow bar). It is the code's own
// margin and nothing else's - the space between the code and the caption is
// --tool-gap, so a tight caption no longer means shaving the quiet zone.
root.style.setProperty('--tool-quiet', quiet.value + 'in')
root.style.setProperty('--tool-gap', gap.value + 'in')
root.style.setProperty('--tool-font', labelfont.value + 'pt')
root.style.setProperty('--tool-align', align.value)
root.style.setProperty('--tool-valign', valign.value)
// Content offset moves what is printed WITHIN the label. Media offset moves
// the label box itself on the stock. They are different fixes: the first
// composes a label, the second corrects a printer whose origin is off.
root.style.setProperty('--tool-content-x', contentx.value + 'in')
root.style.setProperty('--tool-content-y', contenty.value + 'in')
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)
}
for (const name of VAR_NAMES) root.style.removeProperty(name)
}
function applyPreset() {
@@ -417,6 +494,7 @@ function applyPreset() {
padding.value = chosen.padding
quiet.value = chosen.quiet
labelfont.value = chosen.labelfont
if (chosen.gap !== undefined) gap.value = chosen.gap
}
function resetSettings() {
@@ -424,6 +502,10 @@ function resetSettings() {
applyPreset()
layout.value = 'side'
barheight.value = 0.3
align.value = 'center'
valign.value = 'center'
contentx.value = 0
contenty.value = 0
nudgex.value = 0
nudgey.value = 0
}
@@ -440,11 +522,20 @@ function onCsvFile(event) {
}
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')
// The template carries the back columns only when they would be read, so a
// one-sided run is not handed a CSV with two columns it must ignore.
const perRowBack = backenabled.value && backsource.value === 'column'
const csv = perRowBack
? [
'content,label,copies,back,backlabel',
'https://example.com/asset/1,Bay 12 press,1,https://example.com/asset/1/history,Service history',
'WJ-LF-0001,Line 1 fixture,2,WJ-LF-0001-B,Return to Tool Crib',
].join('\n')
: [
'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'
@@ -491,11 +582,11 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
saveSettings()
})
watch([labels, codetype, errorcorrection], renderAll)
watch([pages, codetype, errorcorrection], renderAll)
</script>
<style scoped>
/* One label per page. Chromium reads var() here; the print dialog must be set
/* One page per side. 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; }
@@ -546,6 +637,13 @@ watch([labels, codetype, errorcorrection], renderAll)
.control-row label.grow { flex: 1 1 18rem; }
.control-row label.check {
flex-direction: row;
align-items: center;
gap: 6px;
padding-bottom: 6px;
}
.control-row input,
.control-row select,
.control-row textarea,
@@ -560,6 +658,8 @@ watch([labels, codetype, errorcorrection], renderAll)
font-family: inherit;
}
.control-row label.check input[type="checkbox"] { width: auto; }
.control-row input[type="number"] { width: 7rem; }
.csv-block label {
@@ -571,7 +671,7 @@ watch([labels, codetype, errorcorrection], renderAll)
}
.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; }
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
.fit-advice { flex: 1 1 22rem; align-self: center; }
.sheet {
@@ -581,6 +681,8 @@ watch([labels, codetype, errorcorrection], renderAll)
gap: 10px;
}
/* The label is the MEDIA. It never moves for composition reasons - only the
media offset moves it, and that exists for a printer whose origin is off. */
.label {
box-sizing: border-box;
width: var(--tool-label-w);
@@ -590,15 +692,30 @@ watch([labels, codetype, errorcorrection], renderAll)
color: #000;
overflow: hidden;
display: flex;
align-items: center;
justify-content: var(--tool-align, center);
align-items: var(--tool-valign, 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; }
/* Everything printed sits in here, so it can be aligned and nudged as one
block without the label box following it off the stock. */
.content {
display: flex;
align-items: center;
gap: var(--tool-gap);
max-width: 100%;
max-height: 100%;
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
}
.label.layout-stack .content { flex-direction: column; justify-content: center; }
/* A CODE128 wants the width, so its block takes the label rather than sitting
in the middle of it. */
.label.type-barcode .content { width: 100%; }
.codebox {
flex: 0 0 auto;
@@ -637,8 +754,23 @@ watch([labels, codetype, errorcorrection], renderAll)
overflow-wrap: anywhere;
}
/* Which side you are looking at, on screen only. A two-sided run is a wall of
near-identical rectangles otherwise. */
.side-flag {
position: absolute;
top: 2px;
right: 3px;
font-size: 7px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-light);
}
.label.side-back { outline-style: dotted; }
@media print {
.no-print { display: none !important; }
.side-flag { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; }
.label {
outline: none;