diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23e4fe3..0c3d207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,37 @@ ADR-007 and ADR-002.
## [Unreleased]
+### Added
+
+- **The code generator can print a back side.** A badge card is two-sided and
+ the generator only ever emitted one page per card. The Back side section
+ turns on a second page per card carrying either the same code again, a fixed
+ block of text, its own per-row content (CSV `back` / `backlabel`), or
+ nothing. Page order is a choice, because the two kinds of printer want
+ opposite things: **interleaved** front-back-front-back for a duplex card
+ printer, **grouped** all-fronts-then-all-backs for a single-sided printer
+ that gets its stack flipped and reloaded. Getting that backwards prints
+ perfectly readable cards with the wrong back on every one, which is why the
+ order is pinned by tests rather than by looking at the preview.
+- **The gap between a code and its caption is its own control.** It was the
+ quiet zone doing double duty: closing up a caption meant shaving the clear
+ space a scanner needs, and the only way to get a code to scan was to push the
+ caption away from it. Quiet zone now means only the code's own margin; gap
+ means only the distance to the caption, and adds to the quiet zone rather
+ than eating into it. Every stock preset carries a gap that suits it.
+- **Contents can be positioned inside the label without moving the label.**
+ Align and Vertical place the code and caption block anywhere on the stock,
+ and Content X/Y nudge it from there. The existing X/Y nudge kept its
+ behaviour and is now labelled Media X/Y: it moves the whole printed area, for
+ a printer whose origin is off, which is a different problem from composing a
+ label.
+
+### Changed
+
+- CSV parsing and page building moved out of the generator component into
+ `plugins/tools/frontend/labelPages.js`, where the column contract and the
+ two-sided page order are covered by tests.
+
## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a
diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js
index 381cba5..cdeba7c 100644
--- a/frontend/src/utils/codes.js
+++ b/frontend/src/utils/codes.js
@@ -24,15 +24,19 @@ 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.
+// Label stock actually loaded in the printers, with the code size, padding,
+// quiet zone and code-to-caption gap that fit each one. Sizes are inches,
+// because that is what the stock is sold as and what @page takes.
+//
+// quiet and gap are NOT the same measurement. quiet is the clear space a
+// scanner needs around the code and is fixed by the symbology; gap is only how
+// far the caption sits from it, and is a matter of taste per stock.
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 },
+ { 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, gap: 0.03, 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, gap: 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, gap: 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, gap: 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, gap: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label
diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue
index 6be1b83..9b1cac6 100644
--- a/plugins/tools/frontend/views/CodeGenerator.vue
+++ b/plugins/tools/frontend/views/CodeGenerator.vue
@@ -65,8 +65,10 @@
{{ csvError }}
- Columns: content (required), label, copies.
- A header row is optional; without one the order is content, label, copies.
+ Columns: content (required), label, copies, back, backlabel.
+ A header row is optional; without one the order is content, label, copies, back, backlabel.
@@ -99,11 +101,16 @@
+
-
-
+
+ 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.
+
+
@@ -121,18 +128,107 @@
+
+
Position
-
+
+
+ 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.
+
+
+
+
Back side
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Duplex printer: 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.
+ Single-sided printer: 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.
+
+
+
+
-
- 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.
@@ -145,17 +241,20 @@
-
-
-
-
- {{ label.label }}
+
+
+
+
+
+ {{ page.label }}
+
+ {{ page.side }}
@@ -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)