Refuse a measurement that makes the label stop existing
Some checks failed
CI / backend (push) Failing after 7m11s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m18s
CI / migrations-mysql (push) Failing after 7m5s

Reported as a barcode rendering 176 by 0. A bar height of zero is what does it:
the number went straight into a CSS length, height: 0in is perfectly valid, and
an image sized height: 100% of a zero-height box is zero pixels tall. The label
comes out full width with nothing on it, which reads as a rendering failure
rather than as a field doing exactly what it was told.

A cleared field was the same bug wearing a different hat. An emptied number
input holds '', not 0, so the page emitted `NaNin`; the browser dropped the
declaration and the box fell back to auto height. That one merely looked wrong
instead of vanishing, which is arguably worse for finding it.

Measurements now go through labelVars(), which will not emit NaN, an empty
length, or a zero for anything whose zero means the box stops existing. Padding,
the quiet zone and the gap still take zero, because there zero is a real answer,
and offsets stay signed. It is a separate module because this is exactly the
kind of arithmetic that needs tests rather than a preview: every case in
labelVars.spec.js is a value a number input can actually hold.

The stylesheet now carries fallbacks on its var() reads as well, but they are a
net, not the fix: a custom property set to garbage is substituted and then
dropped at computed value time, and the fallback does not apply - it only covers
a property that is absent entirely.
This commit is contained in:
cproudlock
2026-08-21 12:09:26 -04:00
parent 3703412baf
commit f09d14d28c
4 changed files with 214 additions and 44 deletions

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;