Label generator: back-side codes, pictures, two-column layout, measurement guards
This commit is contained in:
12
CHANGELOG.md
12
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,
|
||||
|
||||
80
plugins/tools/frontend/labelVars.js
Normal file
80
plugins/tools/frontend/labelVars.js
Normal 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),
|
||||
}
|
||||
}
|
||||
76
plugins/tools/frontend/labelVars.spec.js
Normal file
76
plugins/tools/frontend/labelVars.spec.js
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user