You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/packages/printing/src/labels.ts

189 lines
7.6 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 09. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 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>`
}