|
|
import { formatINR } from '@sims/domain'
|
|
|
import { ean13CheckDigit } from '@sims/scanning'
|
|
|
|
|
|
/**
|
|
|
* Shelf/MRP label printing (S2). `ean13Svg` renders a standards-correct EAN-13
|
|
|
* symbol (L/G/R element patterns, first-digit parity) so a scanner reads the
|
|
|
* printed label; `renderLabelSheetHtml` lays labels out as a printable grid for
|
|
|
* a laser sheet or label roll. Pure — no DOM — so both are unit-testable.
|
|
|
*/
|
|
|
|
|
|
// Left-hand odd-parity (L) element patterns, digit 0–9. 7 modules each.
|
|
|
const L: string[] = [
|
|
|
'0001101', '0011001', '0010011', '0111101', '0100011',
|
|
|
'0110001', '0101111', '0111011', '0110111', '0001011',
|
|
|
]
|
|
|
// Right-hand (R) patterns are the ones-complement of L.
|
|
|
const R: string[] = L.map((s) => [...s].map((b) => (b === '0' ? '1' : '0')).join(''))
|
|
|
// Left-hand even-parity (G) patterns are R read right-to-left.
|
|
|
const G: string[] = R.map((s) => [...s].reverse().join(''))
|
|
|
// First digit selects the parity mix of the six left-hand digits.
|
|
|
const PARITY: string[] = [
|
|
|
'LLLLLL', 'LLGLGG', 'LLGGLG', 'LLGGGL', 'LGLLGG',
|
|
|
'LGGLLG', 'LGGGLL', 'LGLGLG', 'LGLGGL', 'LGGLGL',
|
|
|
]
|
|
|
|
|
|
/**
|
|
|
* Strict validation: a 12-digit body gets its check digit computed (via the
|
|
|
* scanning package's `ean13CheckDigit`); a 13-digit code is verified and throws
|
|
|
* on a checksum mismatch. Use for validating catalog input.
|
|
|
*/
|
|
|
export function normalizeEan13(code: string): string {
|
|
|
const digits = code.replace(/\D/g, '')
|
|
|
if (digits.length === 12) return digits + String(ean13CheckDigit(digits))
|
|
|
if (digits.length === 13) {
|
|
|
const cd = ean13CheckDigit(digits.slice(0, 12))
|
|
|
if (cd !== Number(digits[12])) throw new Error(`EAN-13 checksum mismatch: expected ${cd}, got ${digits[12]}`)
|
|
|
return digits
|
|
|
}
|
|
|
throw new Error('EAN-13 needs 12 or 13 digits')
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Lenient 13-digit form for rendering: a 12-digit body gains its computed check
|
|
|
* digit; a 13-digit code is encoded exactly as stored (so the label round-trips
|
|
|
* to the catalog's barcode even when legacy data has an off check digit — the
|
|
|
* label must always print, R18). Only the length is enforced.
|
|
|
*/
|
|
|
function toEan13(code: string): string {
|
|
|
const digits = code.replace(/\D/g, '')
|
|
|
if (digits.length === 12) return digits + String(ean13CheckDigit(digits))
|
|
|
if (digits.length === 13) return digits
|
|
|
throw new Error('EAN-13 needs 12 or 13 digits')
|
|
|
}
|
|
|
|
|
|
/** The 95-module bar/space string (1 = bar): start · 6 left · centre · 6 right · end. */
|
|
|
export function ean13Modules(code: string): string {
|
|
|
const c = toEan13(code)
|
|
|
const first = Number(c[0])
|
|
|
const pattern = PARITY[first]!
|
|
|
let out = '101' // start guard
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
const d = Number(c[1 + i])
|
|
|
out += pattern[i] === 'L' ? L[d]! : G[d]!
|
|
|
}
|
|
|
out += '01010' // centre guard
|
|
|
for (let i = 0; i < 6; i++) {
|
|
|
out += R[Number(c[7 + i])]!
|
|
|
}
|
|
|
out += '101' // end guard
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
const GUARD = new Set<number>()
|
|
|
for (const i of [0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94]) GUARD.add(i)
|
|
|
|
|
|
/** A self-contained SVG EAN-13 symbol with the human-readable digits beneath. */
|
|
|
export function ean13Svg(code13: string, opts: { module?: number; height?: number } = {}): string {
|
|
|
const c = toEan13(code13)
|
|
|
const modules = ean13Modules(c)
|
|
|
const m = opts.module ?? 2
|
|
|
const barH = opts.height ?? 56
|
|
|
const guardH = barH + 7
|
|
|
const quietL = 11 * m
|
|
|
const quietR = 7 * m
|
|
|
const width = quietL + 95 * m + quietR
|
|
|
const totalH = guardH + 14
|
|
|
|
|
|
const bars: string[] = []
|
|
|
for (let i = 0; i < modules.length; i++) {
|
|
|
if (modules[i] !== '1') continue
|
|
|
const h = GUARD.has(i) ? guardH : barH
|
|
|
bars.push(`<rect x="${(quietL + i * m).toFixed(1)}" y="0" width="${m}" height="${h}" />`)
|
|
|
}
|
|
|
|
|
|
// Human-readable: first digit in the left quiet zone, then two groups of six.
|
|
|
const ty = guardH + 11
|
|
|
const leftMid = quietL + (3 + 21) * m
|
|
|
const rightMid = quietL + (50 + 21) * m
|
|
|
const text =
|
|
|
`<text x="${(quietL - 2 * m).toFixed(1)}" y="${ty}" text-anchor="end">${c[0]}</text>` +
|
|
|
`<text x="${leftMid.toFixed(1)}" y="${ty}" text-anchor="middle" letter-spacing="${(1.4 * m).toFixed(1)}">${c.slice(1, 7)}</text>` +
|
|
|
`<text x="${rightMid.toFixed(1)}" y="${ty}" text-anchor="middle" letter-spacing="${(1.4 * m).toFixed(1)}">${c.slice(7)}</text>`
|
|
|
|
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${totalH}" width="${width}" height="${totalH}" role="img" aria-label="EAN-13 ${c}">` +
|
|
|
`<rect x="0" y="0" width="${width}" height="${totalH}" fill="#fff" />` +
|
|
|
`<g fill="#000">${bars.join('')}</g>` +
|
|
|
`<g fill="#000" font-family="monospace" font-size="${(6 * m).toFixed(1)}">${text}</g>` +
|
|
|
`</svg>`
|
|
|
}
|
|
|
|
|
|
export interface LabelInput {
|
|
|
name: string
|
|
|
mrpPaise: number
|
|
|
/** Barcode to print; omitted labels show text only. */
|
|
|
code13?: string
|
|
|
/** Selling price when it differs from MRP. */
|
|
|
pricePaise?: number
|
|
|
}
|
|
|
|
|
|
export interface LabelSheetOptions {
|
|
|
/** Columns in the grid. */
|
|
|
cols: number
|
|
|
/** Label cell size in millimetres. */
|
|
|
labelWmm: number
|
|
|
labelHmm: number
|
|
|
title?: string
|
|
|
printButton?: boolean
|
|
|
}
|
|
|
|
|
|
const esc = (s: string): string =>
|
|
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
|
|
|
|
/** A printable A4 sheet of MRP/barcode labels laid out in a grid. */
|
|
|
export function renderLabelSheetHtml(labels: LabelInput[], opts: LabelSheetOptions): string {
|
|
|
const title = opts.title ?? 'Barcode / MRP Labels'
|
|
|
const cells = labels.map((lab) => {
|
|
|
let bar = ''
|
|
|
if (lab.code13 !== undefined && lab.code13 !== '') {
|
|
|
try {
|
|
|
bar = `<div class="bar">${ean13Svg(lab.code13, { module: 2, height: 40 })}</div>`
|
|
|
} catch {
|
|
|
bar = `<div class="bar muted">no barcode</div>`
|
|
|
}
|
|
|
}
|
|
|
const price = lab.pricePaise !== undefined && lab.pricePaise !== lab.mrpPaise
|
|
|
? `<div class="price">Rate ${formatINR(lab.pricePaise)}</div>`
|
|
|
: ''
|
|
|
return `<div class="label">
|
|
|
<div class="name">${esc(lab.name)}</div>
|
|
|
${bar}
|
|
|
<div class="mrp">MRP ${formatINR(lab.mrpPaise)}</div>
|
|
|
${price}
|
|
|
</div>`
|
|
|
}).join('')
|
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
|
<head>
|
|
|
<meta charset="utf-8" />
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
<title>${esc(title)}</title>
|
|
|
<style>
|
|
|
:root { color-scheme: light; }
|
|
|
* { box-sizing: border-box; }
|
|
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; margin: 0; background: #f4f4f5; color: #111; }
|
|
|
.sheet { width: 210mm; margin: 8mm auto; padding: 8mm; background: #fff; box-shadow: 0 0 6px rgba(0,0,0,.15); }
|
|
|
.grid { display: grid; grid-template-columns: repeat(${opts.cols}, ${opts.labelWmm}mm); gap: 2mm; }
|
|
|
.label { width: ${opts.labelWmm}mm; height: ${opts.labelHmm}mm; border: 1px dashed #bbb; padding: 1.5mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; overflow: hidden; }
|
|
|
.name { font-size: 8pt; font-weight: 600; line-height: 1.1; max-height: 3.4em; overflow: hidden; }
|
|
|
.bar { margin: 1mm 0; }
|
|
|
.bar svg { max-width: 100%; height: auto; }
|
|
|
.mrp { font-size: 9pt; font-weight: 700; }
|
|
|
.price { font-size: 8pt; color: #333; }
|
|
|
.muted { color: #999; font-size: 7pt; }
|
|
|
.no-print { position: fixed; top: 10px; right: 14px; }
|
|
|
.no-print button { font: inherit; padding: 8px 16px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
|
|
|
@media print { body { background: #fff; } .sheet { margin: 0; box-shadow: none; width: auto; } .label { border-color: transparent; } .no-print { display: none; } }
|
|
|
@page { size: A4; margin: 8mm; }
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
${opts.printButton === false ? '' : '<div class="no-print"><button type="button" onclick="window.print()">Print labels</button></div>'}
|
|
|
<div class="sheet">
|
|
|
<div class="grid">${cells}</div>
|
|
|
</div>
|
|
|
</body>
|
|
|
</html>`
|
|
|
}
|