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

@@ -10,6 +10,37 @@ ADR-007 and ADR-002.
## [Unreleased] ## [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 ## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a Printers become a property of the bay rather than of whoever last walked up to a

View File

@@ -24,15 +24,19 @@ import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings' import { getQrLogo } from '@/utils/siteSettings'
// Label stock actually loaded in the printers, with the code size, padding and // Label stock actually loaded in the printers, with the code size, padding,
// quiet zone that fit each one. Sizes are inches, because that is what the // quiet zone and code-to-caption gap that fit each one. Sizes are inches,
// stock is sold as and what @page takes. // 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 = [ 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: '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, labelfont: 10 }, { 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, labelfont: 11 }, { 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, labelfont: 20 }, { 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, labelfont: 12 }, { 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 // CODE128 is what the scanners on the floor are configured for, and a label

View File

@@ -65,8 +65,10 @@
</label> </label>
<p class="control-note" v-if="csvError">{{ csvError }}</p> <p class="control-note" v-if="csvError">{{ csvError }}</p>
<p class="control-hint"> <p class="control-hint">
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code>. Columns: <code>content</code> (required), <code>label</code>, <code>copies</code><span
A header row is optional; without one the order is content, label, copies. 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> </p>
</div> </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-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 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>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>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> </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, <!-- 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 so a code sized off the dot grid gets uneven modules and scans
badly. This says what the current size actually lands on. --> badly. This says what the current size actually lands on. -->
@@ -121,18 +128,107 @@
</p> </p>
</div> </div>
<!-- Position -->
<h4 class="section-heading">Position</h4>
<div class="control-row"> <div class="control-row">
<button class="btn btn-primary" :disabled="!labels.length" @click="printAll"> <label>
Print {{ labels.length }} label{{ labels.length === 1 ? '' : 's' }} 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>
<button class="btn btn-secondary" :disabled="!labels.length" @click="printTest"> <button class="btn btn-secondary" :disabled="!pages.length" @click="printTest">
Print 1 test label Print 1 test {{ backenabled ? 'card (both sides)' : 'label' }}
</button> </button>
<button class="btn btn-secondary" @click="resetSettings">Reset sizing</button> <button class="btn btn-secondary" @click="resetSettings">Reset sizing</button>
</div> </div>
<p v-if="truncated" class="control-note"> <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. - split the CSV and print it in batches.
</p> </p>
<p v-if="overlong.length" class="control-note"> <p v-if="overlong.length" class="control-note">
@@ -145,18 +241,21 @@
<!-- Preview doubles as the print surface: what is on screen is what prints. --> <!-- Preview doubles as the print surface: what is on screen is what prints. -->
<div class="sheet"> <div class="sheet">
<div <div
v-for="(label, index) in visibleLabels" v-for="(page, index) in visiblePages"
:key="index" :key="index"
class="label" class="label"
:class="['layout-' + layout, 'type-' + codetype]" :class="['layout-' + layoutFor(page), 'type-' + codetype, 'side-' + page.side]"
> >
<div class="codebox"> <div class="content">
<div v-if="layoutFor(page) !== 'textonly'" class="codebox">
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" /> <img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
</div> </div>
<div v-if="layout !== 'codeonly' && label.label" class="label-text"> <div v-if="layoutFor(page) !== 'codeonly' && page.label" class="label-text">
{{ label.label }} {{ page.label }}
</div> </div>
</div> </div>
<span v-if="backenabled" class="side-flag">{{ page.side }}</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -165,10 +264,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri } import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
from '@/utils/codes' from '@/utils/codes'
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
// 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 PRESETS = LABEL_PRESETS
@@ -179,6 +275,7 @@ const errorcorrection = ref('M')
const singleContent = ref('') const singleContent = ref('')
const singleLabel = ref('') const singleLabel = ref('')
const singleCopies = ref(1) const singleCopies = ref(1)
const singleBack = ref('')
const csvText = ref('') const csvText = ref('')
const csvError = ref('') const csvError = ref('')
@@ -188,143 +285,112 @@ const labelwidth = ref(1.0)
const labelheight = ref(0.5) const labelheight = ref(0.5)
const padding = ref(0) const padding = ref(0)
const quiet = ref(0.035) const quiet = ref(0.035)
const gap = ref(0.03)
const codesize = ref(0.4286) const codesize = ref(0.4286)
const barheight = ref(0.3) const barheight = ref(0.3)
const labelfont = ref(7) const labelfont = ref(7)
const layout = ref('side') 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 nudgex = ref(0)
const nudgey = ref(0) const nudgey = ref(0)
const dpi = ref(203) 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 images = ref([])
const testMode = ref(false) const testMode = ref(false)
const overlong = ref([]) const overlong = ref([])
const SETTING_KEYS = [ const SETTING_KEYS = [
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding', '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 = { const settingRefs = {
codetype, errorcorrection, preset, labelwidth, labelheight, padding, 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 ------------------------------------------------------------------- // --- rows -------------------------------------------------------------------
// Split one CSV line, honoring quoted fields and "" escapes. // The rows before copies are expanded. CSV parsing and page building both live
function splitCsvLine(line) { // in ../labelPages.js, which is where they can be tested.
const fields = [] const rows = computed(() => {
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') { if (source.value === 'single') {
const content = singleContent.value.trim() const content = singleContent.value.trim()
if (content) { if (!content) return []
const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0 const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0
? singleCopies.value : 1 ? singleCopies.value : 1
rows = [{ content, label: singleLabel.value.trim(), copies }] return [{
content,
label: singleLabel.value.trim(),
copies,
back: singleBack.value.trim(),
backlabel: '',
}]
} }
} else {
try { try {
csvError.value = '' csvError.value = ''
rows = parseCsv(csvText.value) const parsed = parseCsv(csvText.value)
if (csvText.value.trim() && !rows.length) { if (csvText.value.trim() && !parsed.length) {
csvError.value = 'No usable rows found. Every row needs a content value.' csvError.value = 'No usable rows found. Every row needs a content value.'
} }
return parsed
} catch (error) { } catch (error) {
csvError.value = 'Could not read that CSV: ' + error.message csvError.value = 'Could not read that CSV: ' + error.message
rows = [] return []
} }
}
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(() => const built = computed(() => buildPages(rows.value, {
Math.max(0, allLabels.value.length - MAX_LABELS)) 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 // Printing one test label prints the first card only - both of its sides when
// you see is what comes out. // there is a back, because a back that lands upside down is the whole reason
const visibleLabels = computed(() => // to print one first.
testMode.value ? labels.value.slice(0, 1) : labels.value) 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 ------------------------------------------------------------- // --- fit advice -------------------------------------------------------------
// Modules across the QR at the current content and error correction, quiet // Modules across the QR at the current content and error correction, quiet
// zone excluded (the blank label supplies that). // zone excluded (the blank label supplies that).
const qrModules = computed(() => { const qrModules = computed(() => {
const content = labels.value[0]?.content const content = built.value.fronts[0]?.content
if (!content || codetype.value !== 'qr') return 0 if (!content || codetype.value !== 'qr') return 0
return qrModuleCount(content, errorcorrection.value) return qrModuleCount(content, errorcorrection.value)
}) })
const fitAdvice = computed(() => { 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, sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
}) })
if (!fit || codetype.value !== 'qr') return '' if (!fit || codetype.value !== 'qr') return ''
@@ -364,14 +430,14 @@ async function renderAll() {
const token = ++renderToken const token = ++renderToken
const failed = [] const failed = []
const next = [] const next = []
for (const row of labels.value) { for (const page of pages.value) {
try { try {
next.push(await renderOne(row)) next.push(await renderOne(page))
} catch { } catch {
// CODE128 rejects some characters; an unencodable row must be named, // CODE128 rejects some characters; an unencodable row must be named,
// not silently dropped to a blank sticker. // not silently dropped to a blank sticker.
next.push('') next.push('')
failed.push(row.content.slice(0, 20)) failed.push(page.content.slice(0, 20))
} }
if (token !== renderToken) return 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 // @page cannot read a scoped style, so the sizing lives in custom properties on
// the root element. Chromium honors var() in @page size. // 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() { function applyVars() {
const root = document.documentElement const root = document.documentElement
root.style.setProperty('--tool-label-w', labelwidth.value + 'in') 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-code', codesize.value + 'in')
root.style.setProperty('--tool-barh', barheight.value + 'in') root.style.setProperty('--tool-barh', barheight.value + 'in')
// Both symbologies need clear space: a QR wants a quiet zone around it, and // 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 // CODE128 wants one at each end (10x the narrow bar). It is the code's own
// between the code and the label text, so zeroing it for barcodes let the // margin and nothing else's - the space between the code and the caption is
// bars run into the caption. // --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-quiet', quiet.value + 'in')
root.style.setProperty('--tool-gap', gap.value + 'in')
root.style.setProperty('--tool-font', labelfont.value + 'pt') 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-x', nudgex.value + 'in')
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in') root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
} }
function clearVars() { function clearVars() {
const root = document.documentElement const root = document.documentElement
for (const name of ['--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', for (const name of VAR_NAMES) root.style.removeProperty(name)
'--tool-barh', '--tool-quiet', '--tool-font', '--tool-nudge-x', '--tool-nudge-y']) {
root.style.removeProperty(name)
}
} }
function applyPreset() { function applyPreset() {
@@ -417,6 +494,7 @@ function applyPreset() {
padding.value = chosen.padding padding.value = chosen.padding
quiet.value = chosen.quiet quiet.value = chosen.quiet
labelfont.value = chosen.labelfont labelfont.value = chosen.labelfont
if (chosen.gap !== undefined) gap.value = chosen.gap
} }
function resetSettings() { function resetSettings() {
@@ -424,6 +502,10 @@ function resetSettings() {
applyPreset() applyPreset()
layout.value = 'side' layout.value = 'side'
barheight.value = 0.3 barheight.value = 0.3
align.value = 'center'
valign.value = 'center'
contentx.value = 0
contenty.value = 0
nudgex.value = 0 nudgex.value = 0
nudgey.value = 0 nudgey.value = 0
} }
@@ -440,7 +522,16 @@ function onCsvFile(event) {
} }
function downloadTemplate() { function downloadTemplate() {
const csv = [ // 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', 'content,label,copies',
'https://example.com/asset/1,Bay 12 press,1', 'https://example.com/asset/1,Bay 12 press,1',
'WJ-LF-0001,Line 1 fixture,2', 'WJ-LF-0001,Line 1 fixture,2',
@@ -491,11 +582,11 @@ watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
saveSettings() saveSettings()
}) })
watch([labels, codetype, errorcorrection], renderAll) watch([pages, codetype, errorcorrection], renderAll)
</script> </script>
<style scoped> <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%. */ to Margins = None and Scale = 100%. */
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; } @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.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 input,
.control-row select, .control-row select,
.control-row textarea, .control-row textarea,
@@ -560,6 +658,8 @@ watch([labels, codetype, errorcorrection], renderAll)
font-family: inherit; font-family: inherit;
} }
.control-row label.check input[type="checkbox"] { width: auto; }
.control-row input[type="number"] { width: 7rem; } .control-row input[type="number"] { width: 7rem; }
.csv-block label { .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-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; } .fit-advice { flex: 1 1 22rem; align-self: center; }
.sheet { .sheet {
@@ -581,6 +681,8 @@ watch([labels, codetype, errorcorrection], renderAll)
gap: 10px; 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 { .label {
box-sizing: border-box; box-sizing: border-box;
width: var(--tool-label-w); width: var(--tool-label-w);
@@ -590,15 +692,30 @@ watch([labels, codetype, errorcorrection], renderAll)
color: #000; color: #000;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; justify-content: var(--tool-align, center);
align-items: var(--tool-valign, center);
position: relative; position: relative;
left: var(--tool-nudge-x); left: var(--tool-nudge-x);
top: var(--tool-nudge-y); top: var(--tool-nudge-y);
outline: 1px dashed var(--border); outline: 1px dashed var(--border);
} }
.label.layout-stack { flex-direction: column; justify-content: center; } /* Everything printed sits in here, so it can be aligned and nudged as one
.label.layout-codeonly { justify-content: center; } 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 { .codebox {
flex: 0 0 auto; flex: 0 0 auto;
@@ -637,8 +754,23 @@ watch([labels, codetype, errorcorrection], renderAll)
overflow-wrap: anywhere; 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 { @media print {
.no-print { display: none !important; } .no-print { display: none !important; }
.side-flag { display: none !important; }
.sheet { padding: 0; display: block; gap: 0; } .sheet { padding: 0; display: block; gap: 0; }
.label { .label {
outline: none; outline: none;