main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
1166 lines
40 KiB
Vue
1166 lines
40 KiB
Vue
<template>
|
|
<div class="code-generator">
|
|
<div class="page-header no-print">
|
|
<h1>Barcode / QR Generator</h1>
|
|
<div class="header-actions">
|
|
<!-- Sends somebody THIS code, already set up. The content rides in the
|
|
fragment, so it never reaches the server or its logs. -->
|
|
<button class="btn btn-secondary" :disabled="!shareable" @click="copyShareLink">
|
|
{{ sharecopied ? 'Link copied' : 'Copy share link' }}
|
|
</button>
|
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="sharelink" class="share-row no-print">
|
|
<input class="form-control mono" :value="sharelink" readonly
|
|
@focus="$event.target.select()">
|
|
<p class="share-note">
|
|
Opens this page with the content and layout already set. The part after
|
|
the <code>#</code> never reaches the server, so it stays out of the logs
|
|
- but anyone holding the link can read it, so send a password with
|
|
Secure Send instead.
|
|
</p>
|
|
</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>
|
|
<option value="code39">CODE39</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>
|
|
<option value="code39">CODE39</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 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'
|
|
import { labelVars, LABEL_VAR_NAMES } from '../labelVars'
|
|
import { buildFragment, readFragment, coerce } from '../sharelink'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
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([])
|
|
|
|
// The values a first-time visitor starts with, captured before localStorage or
|
|
// a shared link overwrites anything. A share link then carries only what the
|
|
// sender actually changed.
|
|
const DEFAULT_SETTINGS = {}
|
|
|
|
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,
|
|
}
|
|
for (const key of SETTING_KEYS) DEFAULT_SETTINGS[key] = settingRefs[key].value
|
|
|
|
// --- 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) {
|
|
const type = codeTypeFor(page)
|
|
// code39 and barcode (CODE128) share the same CSS class for linear barcode layout
|
|
const typeClass = type === 'code39' ? 'barcode' : type
|
|
return [
|
|
'layout-' + layoutFor(page),
|
|
'type-' + typeClass,
|
|
'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 ''
|
|
const type = codeTypeFor(page)
|
|
if (type === 'qr') {
|
|
return qrSvgDataUri(text, { errorCorrection: errorcorrection.value })
|
|
}
|
|
if (type === 'code39') {
|
|
return barcodeSvgDataUri(text, { format: 'CODE39' })
|
|
}
|
|
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.
|
|
//
|
|
// The values are built in ../labelVars.js, which is also where the rule lives
|
|
// that none of them may be NaN or empty: a cleared number field used to emit
|
|
// `NaNin`, the browser dropped the declaration, and a box that fell back to
|
|
// auto height rendered the barcode 176 wide by 0 high.
|
|
function applyVars() {
|
|
const root = document.documentElement
|
|
const vars = labelVars({
|
|
labelwidth: labelwidth.value,
|
|
labelheight: labelheight.value,
|
|
padding: padding.value,
|
|
codesize: codesize.value,
|
|
barheight: barheight.value,
|
|
// 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
|
|
// the gap, so a tight caption no longer means shaving the quiet zone.
|
|
quiet: quiet.value,
|
|
gap: gap.value,
|
|
labelfont: labelfont.value,
|
|
align: align.value,
|
|
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.
|
|
contentx: contentx.value,
|
|
contenty: contenty.value,
|
|
nudgex: nudgex.value,
|
|
nudgey: nudgey.value,
|
|
picturesize: picturesize.value,
|
|
pictureopacity: pictureopacity.value,
|
|
})
|
|
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value)
|
|
}
|
|
|
|
function clearVars() {
|
|
const root = document.documentElement
|
|
for (const name of LABEL_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 */ }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sharing ---
|
|
//
|
|
// A link that opens this page with the work already in it. Everything rides in
|
|
// the fragment, which is never sent to the server - see ../sharelink.js.
|
|
|
|
const sharelink = ref('')
|
|
const sharecopied = ref(false)
|
|
|
|
// What travels: the typed content and every layout value. The picture does not
|
|
// - a data URL runs to megabytes and no chat client would carry the link.
|
|
function shareState () {
|
|
const state = {
|
|
source: source.value,
|
|
content: singleContent.value,
|
|
label: singleLabel.value,
|
|
copies: singleCopies.value,
|
|
back: singleBack.value,
|
|
csv: source.value === 'csv' ? csvText.value : '',
|
|
}
|
|
for (const key of SETTING_KEYS) state[key] = settingRefs[key].value
|
|
return state
|
|
}
|
|
|
|
const shareable = computed(() =>
|
|
Boolean(source.value === 'csv' ? csvText.value.trim() : singleContent.value.trim()))
|
|
|
|
function makeShareLink () {
|
|
// Defaults are what a first-time visitor starts with, so only the values
|
|
// somebody actually changed end up in the link.
|
|
const fragment = buildFragment(shareState(), DEFAULT_SETTINGS)
|
|
const mount = withBase('/').replace(/\/+$/, '')
|
|
sharelink.value = `${window.location.origin}${mount}/tools/codes${fragment}`
|
|
}
|
|
|
|
async function copyShareLink () {
|
|
makeShareLink()
|
|
try {
|
|
await navigator.clipboard.writeText(sharelink.value)
|
|
sharecopied.value = true
|
|
setTimeout(() => { sharecopied.value = false }, 2000)
|
|
} catch {
|
|
// Clipboard refused (http, or a locked-down browser). The box below still
|
|
// holds the link to copy by hand, which is why it is rendered at all.
|
|
}
|
|
}
|
|
|
|
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 */ }
|
|
|
|
// A shared link wins over this browser's saved settings: somebody following
|
|
// one is asking to see THAT code, not their own last one.
|
|
const shared = readFragment(window.location.hash)
|
|
if (Object.keys(shared).length) {
|
|
if (shared.source) source.value = shared.source === 'csv' ? 'csv' : 'single'
|
|
if (shared.content !== undefined) singleContent.value = shared.content
|
|
if (shared.label !== undefined) singleLabel.value = shared.label
|
|
if (shared.copies !== undefined) singleCopies.value = coerce(shared.copies, singleCopies.value)
|
|
if (shared.back !== undefined) singleBack.value = shared.back
|
|
if (shared.csv) { csvText.value = shared.csv; source.value = 'csv' }
|
|
for (const key of SETTING_KEYS) {
|
|
if (shared[key] !== undefined) {
|
|
settingRefs[key].value = coerce(shared[key], settingRefs[key].value)
|
|
}
|
|
}
|
|
}
|
|
|
|
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, 1in) var(--tool-label-h, 0.5in); margin: 0; }
|
|
|
|
.code-generator { width: 100%; }
|
|
|
|
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
|
|
/* Full width and monospace: the link is long, and somebody copying it by hand
|
|
needs to see where it ends. */
|
|
.share-row { margin: 0 0 1rem; }
|
|
.share-row .mono {
|
|
font-family: Consolas, monospace;
|
|
font-size: 0.85rem;
|
|
width: 100%;
|
|
}
|
|
.share-note {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.85rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* 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, 1in);
|
|
height: var(--tool-label-h, 0.5in);
|
|
padding: var(--tool-pad, 0);
|
|
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, 0.03in);
|
|
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, 0.03in);
|
|
}
|
|
|
|
.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, 0.5in);
|
|
height: var(--tool-pic, 0.5in);
|
|
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, 0.035in);
|
|
width: var(--tool-code, 0.43in);
|
|
height: var(--tool-code, 0.43in);
|
|
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, 0.3in);
|
|
}
|
|
|
|
.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, 7pt);
|
|
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>
|