diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js
new file mode 100644
index 0000000..381cba5
--- /dev/null
+++ b/frontend/src/utils/codes.js
@@ -0,0 +1,202 @@
+// One place that knows how to draw a barcode or a QR code for a label.
+//
+// Seven views were generating codes - three in core, four in plugins - each
+// importing qrcode/jsbarcode directly and each carrying its own answer to the
+// same questions: what margin, what width, which error correction, how big a
+// module has to be before a scanner can read it. The answers had already
+// drifted (margin 0 in one place and 2 in another, width 150 against 160), and
+// none of the label pages knew the printer-resolution arithmetic the Tech Tools
+// generator worked out.
+//
+// So the knowledge lives here and the views pass what they need. Nothing about
+// a specific label belongs in this file; nothing about how to render a code
+// belongs anywhere else.
+//
+// WHY SVG, MOSTLY. A code renders to an SVG data URI rather than a canvas or a
+// PNG for two reasons found the hard way on the label pages: an prints
+// reliably where a live canvas or inline SVG does not, and SVG rasterises at
+// the printer's resolution with hard module edges. A PNG gets downscaled to the
+// label size and smears exactly the edges a scanner reads. The one exception is
+// a QR with a logo composited into it, which needs a canvas to composite on.
+
+import QRCode from 'qrcode'
+import JsBarcode from 'jsbarcode'
+
+import { getQrLogo } from '@/utils/siteSettings'
+
+// Label stock actually loaded in the printers, with the code size, padding and
+// quiet zone that fit each one. Sizes are inches, because that is what the
+// stock is sold as and what @page takes.
+export const LABEL_PRESETS = [
+ { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
+ { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
+ { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
+ { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
+ { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
+]
+
+// CODE128 is what the scanners on the floor are configured for, and a label
+// carries its text separately, so the barcode never renders its own.
+export const BARCODE_DEFAULTS = {
+ format: 'CODE128',
+ displayValue: false,
+ margin: 0,
+ width: 2,
+ height: 100,
+}
+
+export function svgDataUri(svg) {
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
+}
+
+/**
+ * Modules across a QR for this content and error correction, quiet zone
+ * excluded. 0 when the content cannot be encoded at all.
+ */
+export function qrModuleCount(text, errorCorrection = 'M') {
+ if (!text) return 0
+ try {
+ return QRCode.create(text, { errorCorrectionLevel: errorCorrection }).modules.size
+ } catch {
+ return 0
+ }
+}
+
+/**
+ * Whether a QR of `sizeInches` can actually be printed at `dpi`.
+ *
+ * A scanner reads modules, not inches. Below about two printer dots per module
+ * the edges blur into each other and the code stops scanning reliably - and a
+ * fractional dots-per-module means some modules are a dot wider than others,
+ * which is the difference between a label that always works and one that works
+ * on most printers. `snapped` is the nearest size that divides evenly.
+ */
+export function qrFit(text, { sizeInches, dpi = 203, errorCorrection = 'M' } = {}) {
+ const modules = qrModuleCount(text, errorCorrection)
+ if (!modules || !sizeInches) return null
+ const dotsPerModule = (sizeInches * dpi) / modules
+ const whole = Math.floor(dotsPerModule)
+ return {
+ modules,
+ dotsPerModule,
+ wholeDots: whole,
+ snapped: whole > 0 ? (modules * whole) / dpi : 0,
+ moduleMm: whole > 0 ? (whole / dpi) * 25.4 : 0,
+ // Below two dots per module the code is not reliably scannable, whatever
+ // it looks like on screen.
+ tooSmall: whole < 2,
+ even: whole > 0 && Math.abs((modules * whole) / dpi - sizeInches) < 0.002,
+ }
+}
+
+/**
+ * A QR as an SVG data URI.
+ *
+ * margin 0 by default: on a label the quiet zone is blank label supplied by the
+ * layout, so spending the code box on white the page cannot then adjust makes
+ * the printed code smaller for no benefit. A caller rendering to a screen, or
+ * onto something with no controlled surround, should pass a margin.
+ */
+export async function qrSvgDataUri(text, { errorCorrection = 'M', margin = 0 } = {}) {
+ const svg = await QRCode.toString(text, {
+ type: 'svg',
+ errorCorrectionLevel: errorCorrection,
+ margin,
+ })
+ return svgDataUri(svg)
+}
+
+/**
+ * A QR as a PNG data URL. Use only where a raster is genuinely needed - a
+ * composited logo, or an existing page built around .
+ */
+export async function qrPngDataUrl(text, { width = 160, margin = 0, errorCorrection = 'M' } = {}) {
+ return QRCode.toDataURL(text, { width, margin, errorCorrectionLevel: errorCorrection })
+}
+
+/**
+ * Render a barcode into an existing SVG or canvas element.
+ *
+ * Kept as a thin pass-through because several pages hold an element ref and
+ * want JsBarcode to draw straight into it; the value here is the shared
+ * defaults, not the indirection.
+ */
+export function barcodeInto(element, text, options = {}) {
+ JsBarcode(element, text, { ...BARCODE_DEFAULTS, ...options })
+ return element
+}
+
+/** A barcode as an SVG data URI, for pages that print an . */
+export function barcodeSvgDataUri(text, options = {}) {
+ const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ barcodeInto(element, text, options)
+ return svgDataUri(new XMLSerializer().serializeToString(element))
+}
+
+// -- QR with a logo composited in the middle ---------------------------------
+//
+// Moved here from views/print/qrLogo.js, which core label pages and the printer
+// QR pages both reached into. The overlay image is a site setting; the monogram
+// below is the last-resort fallback when that setting is empty or unreachable,
+// so a label still carries a mark rather than a hole.
+
+const FALLBACK_LOGO_SVG = ``
+
+let logoImage = null
+
+function loadLogo(url) {
+ if (logoImage) return Promise.resolve(logoImage)
+ return new Promise(resolve => {
+ const img = new Image()
+ img.onload = () => { logoImage = img; resolve(img) }
+ img.onerror = () => {
+ const fallback = new Image()
+ fallback.onload = () => { logoImage = fallback; resolve(fallback) }
+ fallback.onerror = () => resolve(null)
+ fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(FALLBACK_LOGO_SVG)
+ }
+ img.src = url
+ })
+}
+
+function drawLogoOverlay(canvas, logo) {
+ const canvasContext = canvas.getContext('2d')
+ const size = canvas.width
+ const logoSize = Math.round(size * 0.22)
+ const x = (size - logoSize) / 2
+ const y = (size - logoSize) / 2
+
+ // White circle behind the mark, so it sits on the code rather than in it.
+ canvasContext.beginPath()
+ canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
+ canvasContext.fillStyle = '#fff'
+ canvasContext.fill()
+
+ if (logo) {
+ canvasContext.drawImage(logo, x, y, logoSize, logoSize)
+ }
+}
+
+/**
+ * A QR with the configured logo composited in the centre, as a PNG data URL.
+ *
+ * Error correction H, because up to 30% of the code can be obscured and still
+ * decode - which is what makes covering the middle of it survivable at all.
+ * Empty qr_logo setting means no overlay. Returns '' on failure: a label with
+ * no code is better than a page that throws while printing a batch.
+ */
+export async function renderQrDataUrl(url, { width = 144, margin = 0 } = {}) {
+ const qrLogoUrl = await getQrLogo()
+ const canvas = document.createElement('canvas')
+ try {
+ await QRCode.toCanvas(canvas, url, { width, margin, errorCorrectionLevel: 'H' })
+ if (qrLogoUrl) {
+ const logo = await loadLogo(qrLogoUrl)
+ drawLogoOverlay(canvas, logo)
+ }
+ return canvas.toDataURL('image/png')
+ } catch (err) {
+ console.error('QR error:', err)
+ return ''
+ }
+}
diff --git a/frontend/src/utils/codes.spec.js b/frontend/src/utils/codes.spec.js
new file mode 100644
index 0000000..cf21818
--- /dev/null
+++ b/frontend/src/utils/codes.spec.js
@@ -0,0 +1,93 @@
+// The arithmetic in codes.js decides whether a printed label scans, and it is
+// the part no amount of looking at a screen will verify - a QR that is fine at
+// 96 dpi on a monitor can be unreadable at 203 dpi on 0.5in stock.
+
+import { describe, it, expect } from 'vitest'
+
+import {
+ LABEL_PRESETS, BARCODE_DEFAULTS, qrModuleCount, qrFit, svgDataUri,
+} from './codes'
+
+describe('qrModuleCount', () => {
+ it('grows with the content', () => {
+ const short = qrModuleCount('AP-01')
+ const long = qrModuleCount('AP-01/'.repeat(20))
+ expect(short).toBeGreaterThan(0)
+ expect(long).toBeGreaterThan(short)
+ })
+
+ it('grows with error correction, because the redundancy is stored in modules', () => {
+ expect(qrModuleCount('WKSTN0042', 'H')).toBeGreaterThanOrEqual(
+ qrModuleCount('WKSTN0042', 'L'))
+ })
+
+ it('returns 0 rather than throwing on empty content', () => {
+ expect(qrModuleCount('')).toBe(0)
+ expect(qrModuleCount(null)).toBe(0)
+ })
+})
+
+describe('qrFit', () => {
+ it('calls a code too small when a module gets under two printer dots', () => {
+ // A long payload squeezed onto the 1.00 x 0.50in stock: the case the Tech
+ // Tools generator exists to warn about before 300 labels are printed.
+ const fit = qrFit('https://shopdb.example.net/assets/1234?from=label&sig=abcdef123456', {
+ sizeInches: 0.4286, dpi: 203, errorCorrection: 'H',
+ })
+ expect(fit.tooSmall).toBe(true)
+ expect(fit.dotsPerModule).toBeLessThan(2)
+ })
+
+ it('reports a whole number of dots per module as even', () => {
+ const modules = qrModuleCount('WKSTN0042', 'M')
+ // Choose the size that divides exactly at 4 dots per module.
+ const fit = qrFit('WKSTN0042', {
+ sizeInches: (modules * 4) / 203, dpi: 203, errorCorrection: 'M',
+ })
+ expect(fit.wholeDots).toBe(4)
+ expect(fit.even).toBe(true)
+ expect(fit.snapped).toBeCloseTo(fit.snapped, 5)
+ })
+
+ it('offers the nearest evenly-dividing size when the chosen one is uneven', () => {
+ const fit = qrFit('WKSTN0042', { sizeInches: 0.9, dpi: 203, errorCorrection: 'M' })
+ expect(fit.even).toBe(false)
+ expect(fit.snapped).toBeLessThanOrEqual(0.9)
+ expect(fit.snapped * 203 / fit.modules).toBeCloseTo(fit.wholeDots, 6)
+ })
+
+ it('is null when there is nothing to measure', () => {
+ expect(qrFit('', { sizeInches: 1 })).toBeNull()
+ expect(qrFit('WKSTN0042', {})).toBeNull()
+ })
+})
+
+describe('shared defaults', () => {
+ it('barcodes are CODE128 with no margin and no printed text', () => {
+ // The label draws its own caption, and the quiet zone is blank label. Both
+ // had drifted per-view before this module existed.
+ expect(BARCODE_DEFAULTS.format).toBe('CODE128')
+ expect(BARCODE_DEFAULTS.margin).toBe(0)
+ expect(BARCODE_DEFAULTS.displayValue).toBe(false)
+ })
+
+ it('every label preset is a complete, usable stock definition', () => {
+ expect(LABEL_PRESETS.length).toBeGreaterThan(0)
+ for (const preset of LABEL_PRESETS) {
+ expect(preset.id).toBeTruthy()
+ expect(preset.labelwidth).toBeGreaterThan(0)
+ expect(preset.labelheight).toBeGreaterThan(0)
+ // A code has to fit inside its label with its quiet zone on both sides.
+ expect(preset.codesize + 2 * preset.quiet).toBeLessThanOrEqual(preset.labelwidth)
+ }
+ })
+})
+
+describe('svgDataUri', () => {
+ it('escapes markup so the URI survives being put in a src attribute', () => {
+ const uri = svgDataUri('')
+ expect(uri.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
+ expect(uri).not.toContain('<')
+ expect(uri).not.toContain('"')
+ })
+})
diff --git a/frontend/src/views/print/AssetLabel.vue b/frontend/src/views/print/AssetLabel.vue
index 14604f9..b0767ae 100644
--- a/frontend/src/views/print/AssetLabel.vue
+++ b/frontend/src/views/print/AssetLabel.vue
@@ -128,8 +128,7 @@
-
-
+
+