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.
783 lines
26 KiB
Vue
783 lines
26 KiB
Vue
<template>
|
|
<div>
|
|
<div class="no-print">
|
|
<div class="controls">
|
|
<div class="controls-head">
|
|
<h3>Barcode / QR Generator</h3>
|
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
|
</div>
|
|
|
|
<!-- What to encode -->
|
|
<div class="control-row">
|
|
<label>
|
|
Source
|
|
<select v-model="source">
|
|
<option value="single">One code</option>
|
|
<option value="csv">CSV list</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Code type
|
|
<select v-model="codetype">
|
|
<option value="qr">QR</option>
|
|
<option value="barcode">CODE128</option>
|
|
</select>
|
|
</label>
|
|
<label v-if="codetype === 'qr'">
|
|
Error correction
|
|
<select v-model="errorcorrection">
|
|
<option value="L">L - smallest code</option>
|
|
<option value="M">M - standard</option>
|
|
<option value="Q">Q</option>
|
|
<option value="H">H - most robust</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="source === 'single'" class="control-row">
|
|
<label class="grow">
|
|
Content (text or URL)
|
|
<textarea v-model="singleContent" rows="2"
|
|
placeholder="https://example.com or any text"></textarea>
|
|
</label>
|
|
<label class="grow">
|
|
Label (optional)
|
|
<input v-model="singleLabel" type="text" placeholder="Printed under or beside the code" />
|
|
</label>
|
|
<label>
|
|
Copies
|
|
<input v-model.number="singleCopies" type="number" min="1" max="1000" />
|
|
</label>
|
|
</div>
|
|
|
|
<div v-else class="csv-block">
|
|
<div class="control-row">
|
|
<label>
|
|
CSV file
|
|
<input type="file" accept=".csv,text/csv" @change="onCsvFile" />
|
|
</label>
|
|
<button class="btn btn-secondary" @click="downloadTemplate">Download template</button>
|
|
</div>
|
|
<label class="grow">
|
|
...or paste rows here
|
|
<textarea v-model="csvText" rows="5"
|
|
placeholder="content,label,copies https://example.com,Front desk,1 WJ-LF-0001,Line 1,2"></textarea>
|
|
</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><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>
|
|
|
|
<!-- Media -->
|
|
<h4 class="section-heading">Label stock</h4>
|
|
<div class="control-row">
|
|
<label>
|
|
Preset
|
|
<select v-model="preset" @change="applyPreset">
|
|
<option value="">Custom</option>
|
|
<option v-for="option in PRESETS" :key="option.id" :value="option.id">
|
|
{{ option.name }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label>Label W (in)<input v-model.number="labelwidth" type="number" step="0.01" min="0.25" /></label>
|
|
<label>Label H (in)<input v-model.number="labelheight" type="number" step="0.01" min="0.25" /></label>
|
|
<label>Padding (in)<input v-model.number="padding" type="number" step="0.005" min="0" /></label>
|
|
<label>
|
|
Layout
|
|
<select v-model="layout">
|
|
<option value="side">Code left, label right</option>
|
|
<option value="stack">Code above label</option>
|
|
<option value="codeonly">Code only</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="control-row">
|
|
<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>
|
|
</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. -->
|
|
<div class="control-row">
|
|
<label>
|
|
Printer DPI
|
|
<select v-model.number="dpi">
|
|
<option :value="203">203</option>
|
|
<option :value="300">300</option>
|
|
<option :value="600">600</option>
|
|
</select>
|
|
</label>
|
|
<p v-if="codetype === 'qr' && fitAdvice" class="control-hint fit-advice">
|
|
{{ fitAdvice }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Position -->
|
|
<h4 class="section-heading">Position</h4>
|
|
<div class="control-row">
|
|
<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="!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 }} labels. {{ truncated }} more were left out
|
|
- split the CSV and print it in batches.
|
|
</p>
|
|
<p v-if="overlong.length" class="control-note">
|
|
{{ overlong.length }} row(s) could not be encoded as CODE128 or were empty:
|
|
{{ overlong.slice(0, 3).join(', ') }}{{ overlong.length > 3 ? ' ...' : '' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
|
|
<div class="sheet">
|
|
<div
|
|
v-for="(page, index) in visiblePages"
|
|
:key="index"
|
|
class="label"
|
|
:class="['layout-' + layoutFor(page), 'type-' + codetype, 'side-' + page.side]"
|
|
>
|
|
<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>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
|
|
from '@/utils/codes'
|
|
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
|
|
|
|
const PRESETS = LABEL_PRESETS
|
|
|
|
const source = ref('single')
|
|
const codetype = ref('qr')
|
|
const errorcorrection = ref('M')
|
|
|
|
const singleContent = ref('')
|
|
const singleLabel = ref('')
|
|
const singleCopies = ref(1)
|
|
const singleBack = ref('')
|
|
|
|
const csvText = ref('')
|
|
const csvError = ref('')
|
|
|
|
const preset = ref('zebra1x05')
|
|
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', '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, gap, codesize, barheight, labelfont, layout, align, valign,
|
|
contentx, contenty, nudgex, nudgey, dpi,
|
|
backenabled, backsource, backtext, backlayout, backorder,
|
|
}
|
|
|
|
// --- 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) 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: '',
|
|
}]
|
|
}
|
|
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 []
|
|
}
|
|
})
|
|
|
|
const built = computed(() => buildPages(rows.value, {
|
|
back: {
|
|
enabled: backenabled.value,
|
|
source: backsource.value,
|
|
text: backtext.value.trim(),
|
|
order: backorder.value,
|
|
},
|
|
max: 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 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 = built.value.fronts[0]?.content
|
|
if (!content || codetype.value !== 'qr') return 0
|
|
return qrModuleCount(content, errorcorrection.value)
|
|
})
|
|
|
|
const fitAdvice = computed(() => {
|
|
const fit = qrFit(built.value.fronts[0]?.content, {
|
|
sizeInches: codesize.value, dpi: dpi.value, errorCorrection: errorcorrection.value,
|
|
})
|
|
if (!fit || codetype.value !== 'qr') return ''
|
|
const { modules, dotsPerModule, wholeDots: whole, snapped, moduleMm } = fit
|
|
if (fit.tooSmall) {
|
|
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each - too small to scan `
|
|
+ `reliably. Shorten the content, drop error correction, or use bigger stock.`
|
|
}
|
|
if (fit.even) {
|
|
return `${modules} modules at exactly ${whole} dots each (${moduleMm.toFixed(3)} mm). Good.`
|
|
}
|
|
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each. Uneven - `
|
|
+ `use ${snapped.toFixed(4)} in for a whole ${whole} dots per module.`
|
|
})
|
|
|
|
// --- rendering --------------------------------------------------------------
|
|
|
|
// Each code renders to an SVG data URI, for two reasons. An <img> prints
|
|
// reliably where a live canvas or inline SVG does not (same finding the asset
|
|
// label pages are built on), and SVG rasterizes at the printer's resolution
|
|
// with hard module edges - a PNG would be downscaled to the label size and
|
|
// smear the very edges a scanner reads.
|
|
//
|
|
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
|
|
// so none of the code box is spent on white we cannot then adjust.
|
|
async function renderOne(row) {
|
|
const text = row.content
|
|
if (!text) return ''
|
|
if (codetype.value === 'qr') {
|
|
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
|
|
}
|
|
return barcodeSvgDataUri(text)
|
|
}
|
|
|
|
let renderToken = 0
|
|
async function renderAll() {
|
|
const token = ++renderToken
|
|
const failed = []
|
|
const next = []
|
|
for (const page of pages.value) {
|
|
try {
|
|
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(page.content.slice(0, 20))
|
|
}
|
|
if (token !== renderToken) return
|
|
}
|
|
images.value = next
|
|
overlong.value = failed
|
|
}
|
|
|
|
// --- media vars -------------------------------------------------------------
|
|
|
|
// @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')
|
|
root.style.setProperty('--tool-label-h', labelheight.value + 'in')
|
|
root.style.setProperty('--tool-pad', padding.value + 'in')
|
|
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 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 VAR_NAMES) root.style.removeProperty(name)
|
|
}
|
|
|
|
function applyPreset() {
|
|
const chosen = PRESETS.find(option => option.id === preset.value)
|
|
if (!chosen) return
|
|
labelwidth.value = chosen.labelwidth
|
|
labelheight.value = chosen.labelheight
|
|
codesize.value = chosen.codesize
|
|
padding.value = chosen.padding
|
|
quiet.value = chosen.quiet
|
|
labelfont.value = chosen.labelfont
|
|
if (chosen.gap !== undefined) gap.value = chosen.gap
|
|
}
|
|
|
|
function resetSettings() {
|
|
preset.value = 'zebra1x05'
|
|
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
|
|
}
|
|
|
|
// --- csv helpers ------------------------------------------------------------
|
|
|
|
function onCsvFile(event) {
|
|
const file = event.target.files && event.target.files[0]
|
|
if (!file) return
|
|
const reader = new FileReader()
|
|
reader.onload = () => { csvText.value = String(reader.result || '') }
|
|
reader.onerror = () => { csvError.value = 'Could not read that file.' }
|
|
reader.readAsText(file)
|
|
}
|
|
|
|
function downloadTemplate() {
|
|
// 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'
|
|
link.click()
|
|
URL.revokeObjectURL(link.href)
|
|
}
|
|
|
|
// --- printing ---------------------------------------------------------------
|
|
|
|
function printAll() {
|
|
window.print()
|
|
}
|
|
|
|
async function printTest() {
|
|
testMode.value = true
|
|
await nextTick()
|
|
window.print()
|
|
testMode.value = false
|
|
}
|
|
|
|
// --- lifecycle --------------------------------------------------------------
|
|
|
|
function saveSettings() {
|
|
const state = {}
|
|
for (const key of SETTING_KEYS) state[key] = settingRefs[key].value
|
|
try { localStorage.setItem('toolsCodeGenerator', JSON.stringify(state)) } catch { /* private mode */ }
|
|
}
|
|
|
|
onMounted(() => {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
|
|
for (const key of SETTING_KEYS) {
|
|
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
|
}
|
|
} catch { /* ignore a corrupt entry */ }
|
|
applyVars()
|
|
renderAll()
|
|
})
|
|
|
|
onBeforeUnmount(clearVars)
|
|
|
|
watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
|
|
applyVars()
|
|
saveSettings()
|
|
})
|
|
|
|
watch([pages, codetype, errorcorrection], renderAll)
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* 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; }
|
|
|
|
.no-print { padding: 20px; }
|
|
|
|
.controls {
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
max-width: 66rem;
|
|
}
|
|
|
|
.controls-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.controls-head h3 { margin: 0; }
|
|
|
|
.section-heading {
|
|
margin: 1.25rem 0 0.5rem;
|
|
font-size: 0.9rem;
|
|
color: var(--text-light);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
|
|
.control-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
align-items: flex-end;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.control-row label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 0.8125rem;
|
|
gap: 4px;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.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,
|
|
.csv-block input,
|
|
.csv-block textarea {
|
|
padding: 6px;
|
|
font-size: 0.875rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.control-row label.check input[type="checkbox"] { width: auto; }
|
|
|
|
.control-row input[type="number"] { width: 7rem; }
|
|
|
|
.csv-block label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 0.8125rem;
|
|
gap: 4px;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.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 12px; }
|
|
.fit-advice { flex: 1 1 22rem; align-self: center; }
|
|
|
|
.sheet {
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
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);
|
|
height: var(--tool-label-h);
|
|
padding: var(--tool-pad);
|
|
background: #fff;
|
|
color: #000;
|
|
overflow: hidden;
|
|
display: flex;
|
|
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);
|
|
}
|
|
|
|
/* 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;
|
|
margin: var(--tool-quiet);
|
|
width: var(--tool-code);
|
|
height: var(--tool-code);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
/* A QR is square, so its box is code-size on a side. A CODE128 is wide and
|
|
short: it takes the width left over on the label and only its own bar
|
|
height, or the whole width when nothing shares the label with it. */
|
|
.label.type-barcode .codebox {
|
|
flex: 1 1 auto;
|
|
width: auto;
|
|
min-width: 0;
|
|
height: var(--tool-barh);
|
|
}
|
|
|
|
.code-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.label-text {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
text-align: center;
|
|
font-family: "Arial Narrow", Arial, Helvetica, sans-serif;
|
|
font-size: var(--tool-font);
|
|
font-weight: 700;
|
|
line-height: 1.05;
|
|
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;
|
|
break-after: page;
|
|
page-break-after: always;
|
|
}
|
|
.label:last-of-type { break-after: auto; page-break-after: auto; }
|
|
}
|
|
</style>
|