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)