Label generator: back-side codes, pictures, two-column layout, measurement guards

This commit is contained in:
2026-08-21 12:38:08 -04:00
parent 74392ccad2
commit 2719589c6e
4 changed files with 214 additions and 44 deletions

View File

@@ -51,6 +51,18 @@ ADR-007 and ADR-002.
### Fixed
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
it: the value went straight into a CSS length, `height: 0in` is perfectly
valid, and an image at `height: 100%` of a zero-height box is zero pixels
tall. A cleared field was its own version of the same bug - `'' + 'in'` is not
a length, the browser dropped the declaration, and the box fell back to auto
height. Every measurement now goes through `labelVars()`, which will not emit
NaN, an empty length, or a zero where a zero means the box stops existing;
padding, quiet zone and gap still accept zero, because there it is a real
answer. The stylesheet carries its own fallbacks too, though those only cover
a property that is absent - a property set to garbage is dropped at computed
value time and takes the fallback with it, which is why the sanitiser is the
fix and the CSS is only a net.
- **A caption could eat the barcode.** The code box and the caption both grew,
so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on
1.00 x 0.50in stock left a 36px stub, which is a code that is present,

View File

@@ -0,0 +1,80 @@
// The label's measurements, turned into the CSS custom properties the preview
// and @page read.
//
// This exists because of one failure mode. A number input that is cleared does
// not hold 0, it holds an empty string, and `'' + 'in'` is not a length. The
// browser drops the whole declaration, the box falls back to `height: auto`,
// and an image sized `height: 100%` of an auto-height parent computes to ZERO.
// The label then renders 176x0: full width, no height, no barcode - which
// reads as "the code stopped rendering" and not as "a field is empty".
//
// So nothing here may emit NaN, an empty string, or a negative length. Every
// value falls back to something printable, and the fallbacks are the defaults
// the page ships with.
export const LABEL_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',
]
export const VAR_FALLBACKS = {
labelwidth: 1, labelheight: 0.5, padding: 0, codesize: 0.4286, barheight: 0.3,
quiet: 0.035, gap: 0.03, labelfont: 7, picturesize: 0.5, pictureopacity: 0.15,
}
/** A length that must be greater than zero for the box to exist at all. */
function positive(value, fallback) {
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : fallback
}
/** A length that may legitimately be zero - padding, a quiet zone, a gap. */
function nonNegative(value, fallback) {
const number = Number(value)
return Number.isFinite(number) && number >= 0 ? number : fallback
}
/** An offset, which is signed: -0.05in is a real answer. */
function offset(value) {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const ALIGNMENTS = ['flex-start', 'center', 'flex-end']
function alignment(value) {
return ALIGNMENTS.includes(value) ? value : 'center'
}
/**
* Map the generator's settings to CSS custom properties, in inches.
*
* Returns a plain object of property name to value, ready to hand to
* `style.setProperty`. Every entry is a valid CSS value whatever it was given.
*/
export function labelVars(state = {}) {
const inches = value => value + 'in'
const opacity = Number(state.pictureopacity)
return {
'--tool-label-w': inches(positive(state.labelwidth, VAR_FALLBACKS.labelwidth)),
'--tool-label-h': inches(positive(state.labelheight, VAR_FALLBACKS.labelheight)),
'--tool-pad': inches(nonNegative(state.padding, VAR_FALLBACKS.padding)),
'--tool-code': inches(positive(state.codesize, VAR_FALLBACKS.codesize)),
'--tool-barh': inches(positive(state.barheight, VAR_FALLBACKS.barheight)),
'--tool-quiet': inches(nonNegative(state.quiet, VAR_FALLBACKS.quiet)),
'--tool-gap': inches(nonNegative(state.gap, VAR_FALLBACKS.gap)),
'--tool-font': positive(state.labelfont, VAR_FALLBACKS.labelfont) + 'pt',
'--tool-align': alignment(state.align),
'--tool-valign': alignment(state.valign),
'--tool-content-x': inches(offset(state.contentx)),
'--tool-content-y': inches(offset(state.contenty)),
'--tool-nudge-x': inches(offset(state.nudgex)),
'--tool-nudge-y': inches(offset(state.nudgey)),
'--tool-pic': inches(positive(state.picturesize, VAR_FALLBACKS.picturesize)),
'--tool-pic-opacity': String(
Number.isFinite(opacity) && opacity > 0 && opacity <= 1
? opacity : VAR_FALLBACKS.pictureopacity),
}
}

View File

@@ -0,0 +1,76 @@
// The 176x0 label: a cleared number field emitted `NaNin`, the browser dropped
// the declaration, and the code box fell back to auto height, which is zero for
// an image sized at 100% of it. Every case below is a value a number input can
// actually hold.
import { describe, it, expect } from 'vitest'
import { labelVars, LABEL_VAR_NAMES, VAR_FALLBACKS } from './labelVars'
const EMPTY_INPUT = '' // what v-model.number leaves behind on a cleared field
describe('labelVars', () => {
it('emits every property the stylesheet reads', () => {
const vars = labelVars({})
for (const name of LABEL_VAR_NAMES) expect(vars[name]).toBeTruthy()
})
it('never emits NaN or an empty length, whatever it is handed', () => {
for (const junk of [EMPTY_INPUT, NaN, null, undefined, 'abc', Infinity]) {
const vars = labelVars({
labelwidth: junk, labelheight: junk, barheight: junk, codesize: junk,
quiet: junk, gap: junk, padding: junk, labelfont: junk, picturesize: junk,
contentx: junk, contenty: junk, nudgex: junk, nudgey: junk,
pictureopacity: junk,
})
for (const name of LABEL_VAR_NAMES) {
expect(vars[name]).not.toMatch(/NaN|Infinity|undefined|null/)
expect(vars[name]).not.toBe('in')
}
}
})
it('falls back to a printable bar height rather than a zero-height box', () => {
expect(labelVars({ barheight: EMPTY_INPUT })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
expect(labelVars({ barheight: 0 })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
expect(labelVars({ barheight: -2 })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
})
it('keeps a real measurement', () => {
const vars = labelVars({ labelwidth: 2.13, labelheight: 3.38, barheight: 0.5 })
expect(vars['--tool-label-w']).toBe('2.13in')
expect(vars['--tool-label-h']).toBe('3.38in')
expect(vars['--tool-barh']).toBe('0.5in')
})
it('lets padding, quiet zone and gap be zero, because zero is a real answer', () => {
const vars = labelVars({ padding: 0, quiet: 0, gap: 0 })
expect(vars['--tool-pad']).toBe('0in')
expect(vars['--tool-quiet']).toBe('0in')
expect(vars['--tool-gap']).toBe('0in')
})
it('keeps offsets signed - a negative nudge is the whole point of a nudge', () => {
const vars = labelVars({ contentx: -0.05, nudgey: -0.1, contenty: 0.02 })
expect(vars['--tool-content-x']).toBe('-0.05in')
expect(vars['--tool-nudge-y']).toBe('-0.1in')
expect(vars['--tool-content-y']).toBe('0.02in')
})
it('refuses an alignment it does not recognise', () => {
expect(labelVars({ align: 'flex-end' })['--tool-align']).toBe('flex-end')
expect(labelVars({ align: 'sideways' })['--tool-align']).toBe('center')
expect(labelVars({ valign: EMPTY_INPUT })['--tool-valign']).toBe('center')
})
it('clamps watermark opacity into a range that still prints', () => {
expect(labelVars({ pictureopacity: 0.4 })['--tool-pic-opacity']).toBe('0.4')
expect(labelVars({ pictureopacity: 0 })['--tool-pic-opacity'])
.toBe(String(VAR_FALLBACKS.pictureopacity))
expect(labelVars({ pictureopacity: 5 })['--tool-pic-opacity'])
.toBe(String(VAR_FALLBACKS.pictureopacity))
})
})

View File

@@ -342,6 +342,7 @@ 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'
const PRESETS = LABEL_PRESETS
@@ -591,43 +592,44 @@ async function renderAll() {
// @page cannot read a scoped style, so the sizing lives in custom properties on
// the root element. Chromium honors var() in @page size.
const VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
'--tool-pic', '--tool-pic-opacity',
]
//
// 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
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))
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 VAR_NAMES) root.style.removeProperty(name)
for (const name of LABEL_VAR_NAMES) root.style.removeProperty(name)
}
function applyPreset() {
@@ -748,7 +750,7 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
<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; }
@page { size: var(--tool-label-w, 1in) var(--tool-label-h, 0.5in); margin: 0; }
.code-generator { width: 100%; }
@@ -875,9 +877,9 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
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);
width: var(--tool-label-w, 1in);
height: var(--tool-label-h, 0.5in);
padding: var(--tool-pad, 0);
background: #fff;
color: #000;
overflow: hidden;
@@ -898,7 +900,7 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.content {
display: flex;
align-items: center;
gap: var(--tool-gap);
gap: var(--tool-gap, 0.03in);
position: relative;
z-index: 1;
transform: translate(var(--tool-content-x, 0), var(--tool-content-y, 0));
@@ -908,7 +910,7 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.codegroup {
display: flex;
align-items: center;
gap: var(--tool-gap);
gap: var(--tool-gap, 0.03in);
}
.label.layout-stack .codegroup { flex-direction: column; justify-content: center; }
@@ -923,8 +925,8 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.picturebox {
flex: 0 0 auto;
width: var(--tool-pic);
height: var(--tool-pic);
width: var(--tool-pic, 0.5in);
height: var(--tool-pic, 0.5in);
display: flex;
align-items: center;
justify-content: center;
@@ -952,9 +954,9 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
.codebox {
flex: 0 0 auto;
margin: var(--tool-quiet);
width: var(--tool-code);
height: var(--tool-code);
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;
@@ -969,7 +971,7 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
/* 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);
height: var(--tool-barh, 0.3in);
}
.label.layout-stack.type-barcode .codebox { min-width: 100%; }
@@ -998,7 +1000,7 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
min-width: 0;
text-align: center;
font-family: "Arial Narrow", Arial, Helvetica, sans-serif;
font-size: var(--tool-font);
font-size: var(--tool-font, 7pt);
font-weight: 700;
line-height: 1.05;
overflow-wrap: anywhere;