Reported as the CODE128 not appearing in the preview. It was appearing, in 44 of the 45 preset and layout combinations swept - as a 36 pixel stub. The code box and the caption were both allowed to grow, so a caption of any length took half the label and then kept taking, and on 1.00 x 0.50in stock a few words of caption left a barcode too narrow to scan. Present, useless, and indisinguishable from a failed render. The caption now takes the room it needs and no more, and a CODE128 holds at least 40% of the label, or all of it when stacked: past that there are too few narrow bars left to read, and a caption that wraps or clips is the better trade. So that a real failure can never again look like this one, an empty code box now says so in red in the preview. It is screen-only - the marker must never reach a printed label. The settings were a narrow column with the preview trailing below them, which put the labels being adjusted off the bottom of the screen. Settings on the left, labels on the right in a column that stays in view and scrolls on its own, folding back to one column on a narrow window. That scrolling column is a print hazard worth naming: an ancestor with its own overflow clips a multi-page print to whatever happened to be on screen. Print resets it, and the check is a six-page two-sided run coming out as six pages with the label as the only clipping box in the chain.
1049 lines
36 KiB
Vue
1049 lines
36 KiB
Vue
<template>
|
|
<div class="code-generator">
|
|
<div class="page-header no-print">
|
|
<h1>Barcode / QR Generator</h1>
|
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
|
</div>
|
|
|
|
<div class="workspace">
|
|
<div class="options no-print">
|
|
<div class="controls card">
|
|
<!-- 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="backCarriesOwnContent">, <code>back</code>, <code>backlabel</code></span>.
|
|
A header row is optional; without one the order is content, label, copies<span
|
|
v-if="backCarriesOwnContent">, 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">Center</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>
|
|
|
|
<!-- Picture -->
|
|
<h4 class="section-heading">Picture</h4>
|
|
<div class="control-row">
|
|
<label>
|
|
Image file
|
|
<input type="file" accept="image/*" @change="onPictureFile" />
|
|
</label>
|
|
<button v-if="pictureUrl" class="btn btn-secondary" @click="clearPicture">Remove picture</button>
|
|
<template v-if="pictureUrl">
|
|
<label>Size (in)<input v-model.number="picturesize" type="number" step="0.01" min="0.05" /></label>
|
|
<label>
|
|
Where
|
|
<select v-model="pictureposition">
|
|
<option value="above">Above the code</option>
|
|
<option value="beside">Beside the code</option>
|
|
<option value="behind">Behind everything (watermark)</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Which side
|
|
<select v-model="pictureside">
|
|
<option value="front">Front only</option>
|
|
<option value="back">Back only</option>
|
|
<option value="both">Both sides</option>
|
|
</select>
|
|
</label>
|
|
<label v-if="pictureposition === 'behind'">
|
|
Opacity
|
|
<input v-model.number="pictureopacity" type="number" step="0.05" min="0.05" max="1" />
|
|
</label>
|
|
</template>
|
|
</div>
|
|
|
|
<p class="control-hint">
|
|
The picture never leaves the browser, so this works on an air-gapped
|
|
site. It is also NOT remembered between visits, unlike the sizing -
|
|
an image is far too big to keep in the settings the page saves.
|
|
A watermark behind a code costs the scanner contrast: keep the opacity
|
|
low and test a scan before printing a run.
|
|
</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="code">The same code again</option>
|
|
<option value="column">Its own code (per row)</option>
|
|
<option value="text">Fixed text, no code</option>
|
|
<option value="blank">Nothing - blank back</option>
|
|
</select>
|
|
</label>
|
|
<label v-if="backCarriesCode">
|
|
Back code type
|
|
<select v-model="backcodetype">
|
|
<option value="same">Same as the front</option>
|
|
<option value="qr">QR</option>
|
|
<option value="barcode">CODE128</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" :disabled="backCarriesCode">Text only, no code</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="source === 'single' && backCarriesOwnContent">
|
|
Back content (encoded on the back)
|
|
<textarea v-model="singleBack" rows="2"
|
|
placeholder="A second code for the reverse - text or URL"></textarea>
|
|
</label>
|
|
<label class="grow" v-if="backsource === 'text' || backCarriesOwnContent">
|
|
Back text
|
|
<textarea v-model="backtext" rows="2"
|
|
placeholder="Property of GE Aerospace - return to IT"></textarea>
|
|
</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, so everything around it is chrome that print must strip. -->
|
|
<div class="preview">
|
|
<div class="preview-head no-print">
|
|
<h4 class="section-heading">Preview</h4>
|
|
<span class="preview-count">
|
|
{{ cardCount }} label{{ cardCount === 1 ? '' : 's' }}<span
|
|
v-if="backenabled">, {{ pages.length }} pages</span>
|
|
</span>
|
|
</div>
|
|
<div class="sheet">
|
|
<div
|
|
v-for="item in visible"
|
|
:key="item.index"
|
|
class="label"
|
|
:class="labelClasses(item.page)"
|
|
>
|
|
<img v-if="showPicture(item.page) && pictureposition === 'behind'"
|
|
:src="pictureUrl" class="picture-behind" alt="" />
|
|
<div class="content">
|
|
<div v-if="showPicture(item.page) && pictureposition !== 'behind'" class="picturebox">
|
|
<img :src="pictureUrl" class="picture-img" alt="" />
|
|
</div>
|
|
<div class="codegroup">
|
|
<div v-if="layoutFor(item.page) !== 'textonly'" class="codebox">
|
|
<img v-if="images[item.index]" :src="images[item.index]" class="code-img" alt="" />
|
|
<!-- An empty code box is indistinguishable from a label that
|
|
simply has no code on it, which is how a failed render
|
|
reads as "the barcode does not appear". Say so, on screen
|
|
only - the printed label must never carry this. -->
|
|
<span v-else class="code-missing">no code</span>
|
|
</div>
|
|
<div v-if="layoutFor(item.page) !== 'codeonly' && item.page.label" class="label-text">
|
|
{{ item.page.label }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span v-if="backenabled" class="side-flag">{{ item.page.side }}</span>
|
|
</div>
|
|
<p v-if="!pages.length" class="preview-empty no-print">
|
|
Nothing to print yet - enter content above.
|
|
</p>
|
|
</div>
|
|
</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)
|
|
|
|
// The image itself is deliberately NOT a saved setting: a data URL runs to
|
|
// megabytes and would blow the localStorage quota, taking every sizing value
|
|
// with it. Only how it is placed persists.
|
|
const pictureUrl = ref('')
|
|
const picturesize = ref(0.5)
|
|
const pictureposition = ref('above')
|
|
const pictureside = ref('front')
|
|
const pictureopacity = ref(0.15)
|
|
|
|
const backenabled = ref(false)
|
|
const backsource = ref('code')
|
|
const backcodetype = ref('same')
|
|
const backtext = ref('')
|
|
const backlayout = ref('stack')
|
|
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',
|
|
'picturesize', 'pictureposition', 'pictureside', 'pictureopacity',
|
|
'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder',
|
|
]
|
|
const settingRefs = {
|
|
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
|
|
quiet, gap, codesize, barheight, labelfont, layout, align, valign,
|
|
contentx, contenty, nudgex, nudgey, dpi,
|
|
picturesize, pictureposition, pictureside, pictureopacity,
|
|
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
|
|
}
|
|
|
|
// --- back side --------------------------------------------------------------
|
|
|
|
const backCarriesCode = computed(() =>
|
|
backsource.value === 'code' || backsource.value === 'column')
|
|
|
|
const backCarriesOwnContent = computed(() =>
|
|
backenabled.value && backsource.value === 'column')
|
|
|
|
// A back that carries a code and a back laid out as text-only cancel each other
|
|
// out, and the result is a blank card that looks like the code failed to
|
|
// render. Keep the two honest in both directions, including for settings
|
|
// restored from a previous visit.
|
|
function reconcileBackLayout() {
|
|
if (backCarriesCode.value && backlayout.value === 'textonly') backlayout.value = 'stack'
|
|
if (!backCarriesCode.value && backlayout.value !== 'textonly') backlayout.value = 'textonly'
|
|
}
|
|
|
|
watch(backsource, reconcileBackLayout)
|
|
|
|
/** QR or CODE128 for this page. The back may deliberately differ from the
|
|
* front: a QR that opens a record, a CODE128 the bench scanner already
|
|
* understands. */
|
|
function codeTypeFor(page) {
|
|
if (page.side !== 'back') return codetype.value
|
|
return backcodetype.value === 'same' ? codetype.value : backcodetype.value
|
|
}
|
|
|
|
function layoutFor(page) {
|
|
return page.side === 'back' ? backlayout.value : layout.value
|
|
}
|
|
|
|
function showPicture(page) {
|
|
if (!pictureUrl.value) return false
|
|
if (pictureside.value === 'both') return true
|
|
return pictureside.value === page.side
|
|
}
|
|
|
|
function labelClasses(page) {
|
|
return [
|
|
'layout-' + layoutFor(page),
|
|
'type-' + codeTypeFor(page),
|
|
'side-' + page.side,
|
|
showPicture(page) && pictureposition.value !== 'behind' ? 'pic-' + pictureposition.value : '',
|
|
]
|
|
}
|
|
|
|
// --- 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)
|
|
|
|
// What the preview renders, each item carrying its index into `images`. The
|
|
// index has to travel with the page: a test print shows two of the pages and
|
|
// the rendered codes are keyed by position in the full run, so pairing them by
|
|
// loop position printed the wrong code onto the test card.
|
|
const visible = computed(() => {
|
|
const all = pages.value.map((page, index) => ({ page, index }))
|
|
if (!testMode.value) return all
|
|
const { fronts, backs } = built.value
|
|
if (!fronts.length) return []
|
|
const items = [{ page: fronts[0], index: 0 }]
|
|
if (backenabled.value && backs.length) {
|
|
// Where the first back sits depends on the order the run is emitted in.
|
|
items.push({
|
|
page: backs[0],
|
|
index: backorder.value === 'grouped' ? fronts.length : 1,
|
|
})
|
|
}
|
|
return items
|
|
})
|
|
|
|
// --- 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(page) {
|
|
const text = page.content
|
|
if (!text) return ''
|
|
if (codeTypeFor(page) === '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',
|
|
'--tool-pic', '--tool-pic-opacity',
|
|
]
|
|
|
|
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')
|
|
root.style.setProperty('--tool-pic', picturesize.value + 'in')
|
|
root.style.setProperty('--tool-pic-opacity', String(pictureopacity.value))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// --- picture ----------------------------------------------------------------
|
|
|
|
function onPictureFile(event) {
|
|
const file = event.target.files && event.target.files[0]
|
|
if (!file) return
|
|
const reader = new FileReader()
|
|
reader.onload = () => { pictureUrl.value = String(reader.result || '') }
|
|
reader.onerror = () => { pictureUrl.value = '' }
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
function clearPicture() {
|
|
pictureUrl.value = ''
|
|
}
|
|
|
|
// --- 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 csv = backCarriesOwnContent.value
|
|
? [
|
|
'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 */ }
|
|
reconcileBackLayout()
|
|
applyVars()
|
|
renderAll()
|
|
})
|
|
|
|
onBeforeUnmount(clearVars)
|
|
|
|
watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
|
|
applyVars()
|
|
saveSettings()
|
|
})
|
|
|
|
watch([pages, codetype, backcodetype, 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; }
|
|
|
|
.code-generator { width: 100%; }
|
|
|
|
/* Settings on the left, the labels themselves on the right, where they stay in
|
|
view while the settings that change them are being worked. The preview
|
|
column scrolls on its own so a 500-label run cannot push the controls off
|
|
the screen. */
|
|
.workspace {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
|
|
gap: 20px;
|
|
align-items: start;
|
|
}
|
|
|
|
.preview {
|
|
position: sticky;
|
|
top: 12px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 16px 16px;
|
|
max-height: calc(100vh - var(--main-pad-top) - 24px);
|
|
overflow: auto;
|
|
}
|
|
|
|
.preview-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.preview-head .section-heading { margin-top: 0.25rem; }
|
|
|
|
.preview-count { font-size: 0.8125rem; color: var(--text-light); }
|
|
|
|
.preview-empty { color: var(--text-light); font-size: 0.8125rem; margin: 0; }
|
|
|
|
/* One column once the two would be too narrow to work in. */
|
|
@media (max-width: 1200px) {
|
|
.workspace { grid-template-columns: minmax(0, 1fr); }
|
|
.preview { position: static; max-height: none; }
|
|
}
|
|
|
|
.controls {
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.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 0;
|
|
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. No max-width or
|
|
max-height: overflow must CLIP against the label, the way it did before this
|
|
wrapper existed. Constrain it and the flex children shrink instead, and the
|
|
one that gives way is the barcode - it is the only box allowed to flex. */
|
|
.content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--tool-gap);
|
|
position: relative;
|
|
z-index: 1;
|
|
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
|
|
}
|
|
|
|
/* The code and its caption, which is the group a picture sits above or beside. */
|
|
.codegroup {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--tool-gap);
|
|
}
|
|
|
|
.label.layout-stack .codegroup { flex-direction: column; justify-content: center; }
|
|
|
|
.label.pic-above .content { flex-direction: column; }
|
|
.label.pic-beside .content { flex-direction: row; }
|
|
|
|
/* A CODE128 wants the width, so its block takes the label rather than sitting
|
|
in the middle of it. */
|
|
.label.type-barcode .content,
|
|
.label.type-barcode .codegroup { width: 100%; }
|
|
|
|
.picturebox {
|
|
flex: 0 0 auto;
|
|
width: var(--tool-pic);
|
|
height: var(--tool-pic);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.picture-img {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
/* A watermark sits under the code, not in it, and only as dark as the setting
|
|
says - a scanner reads contrast, and this is the control that costs it. */
|
|
.picture-behind {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: var(--tool-pic);
|
|
height: var(--tool-pic);
|
|
transform: translate(-50%, -50%);
|
|
object-fit: contain;
|
|
opacity: var(--tool-pic-opacity, 0.15);
|
|
z-index: 0;
|
|
}
|
|
|
|
.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;
|
|
/* Never below 40% of the label: past that a CODE128 is too few narrow bars
|
|
to scan, so it is better for the caption to wrap or clip. */
|
|
min-width: 40%;
|
|
height: var(--tool-barh);
|
|
}
|
|
|
|
.label.layout-stack.type-barcode .codebox { min-width: 100%; }
|
|
|
|
/* Screen-only, and deliberately loud: an empty box means the render failed. */
|
|
.code-missing {
|
|
font-family: Arial, Helvetica, sans-serif;
|
|
font-size: 6px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--danger);
|
|
}
|
|
|
|
.code-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
/* The caption takes what it needs and no more. Both boxes used to grow, so a
|
|
caption of any length halved the barcode and then kept going - at 1x0.5in
|
|
with a couple of words it left a 36px stub that reads as "the code did not
|
|
render". A code that cannot be scanned is not a label. */
|
|
.label-text {
|
|
flex: 0 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);
|
|
z-index: 2;
|
|
}
|
|
|
|
.label.side-back { outline-style: dotted; }
|
|
|
|
@media print {
|
|
.side-flag { display: none !important; }
|
|
.code-missing { display: none !important; }
|
|
.sheet { padding: 0; display: block; gap: 0; }
|
|
/* The preview column is chrome. Its overflow matters most: a scrolling
|
|
ancestor clips a multi-page print to whatever was on screen. */
|
|
.workspace { display: block; }
|
|
.preview {
|
|
position: static;
|
|
max-height: none;
|
|
overflow: visible;
|
|
border: 0;
|
|
border-radius: 0;
|
|
padding: 0;
|
|
background: none;
|
|
}
|
|
/* The back's dotted variant outspecifies a bare .label reset, so it printed
|
|
a dashed rule down the edge of every second card. */
|
|
.label,
|
|
.label.side-back {
|
|
outline: none;
|
|
break-after: page;
|
|
page-break-after: always;
|
|
}
|
|
.label:last-of-type { break-after: auto; page-break-after: auto; }
|
|
}
|
|
</style>
|