diff --git a/CHANGELOG.md b/CHANGELOG.md index 354e991..ec0d265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/plugins/tools/frontend/labelVars.js b/plugins/tools/frontend/labelVars.js new file mode 100644 index 0000000..33625db --- /dev/null +++ b/plugins/tools/frontend/labelVars.js @@ -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), + } +} diff --git a/plugins/tools/frontend/labelVars.spec.js b/plugins/tools/frontend/labelVars.spec.js new file mode 100644 index 0000000..af67a82 --- /dev/null +++ b/plugins/tools/frontend/labelVars.spec.js @@ -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)) + }) +}) diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 9782956..834d14b 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -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)