Label generator: gap, position and a back side
This commit is contained in:
31
CHANGELOG.md
31
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user