import { toSVG } from "bwip-js/browser"; import { sanitizeDownloadFilename } from "@add-ideas/toolbox-helpers"; import { strToU8, zipSync } from "fflate"; export const BARCODE_FORMATS = [ ["qrcode", "QR Code"], ["code128", "Code 128"], ["gs1-128", "GS1-128"], ["ean13", "EAN-13"], ["ean8", "EAN-8"], ["upca", "UPC-A"], ["code39", "Code 39"], ["datamatrix", "Data Matrix"], ["pdf417", "PDF417"], ["azteccode", "Aztec Code"], ] as const; export type BarcodeFormat = (typeof BARCODE_FORMATS)[number][0]; export interface BarcodeOptions { format: BarcodeFormat; text: string; scale: number; padding: number; includeText: boolean; } export interface BarcodeSvg { svg: string; width: number; height: number; quietZone: string; } const LINEAR = new Set([ "code128", "gs1-128", "ean13", "ean8", "upca", "code39", ]); export function renderBarcodeSvg(options: BarcodeOptions): BarcodeSvg { if (!BARCODE_FORMATS.some(([format]) => format === options.format)) throw new Error("Unsupported barcode format."); if (!options.text || options.text.length > 4_096) throw new Error("Payload must contain 1–4,096 characters."); if ( !Number.isInteger(options.scale) || options.scale < 1 || options.scale > 8 ) throw new Error("Scale must be an integer from 1 to 8."); if ( !Number.isInteger(options.padding) || options.padding < 0 || options.padding > 64 ) throw new Error("Padding must be an integer from 0 to 64."); const linear = LINEAR.has(options.format); const svg = toSVG({ bcid: options.format, text: options.text, scale: options.scale, ...(linear ? { height: 15, includetext: options.includeText } : {}), padding: options.padding, backgroundcolor: "FFFFFF", barcolor: "111111", textcolor: "111111", }); if (/<(?:script|foreignObject)|\bon\w+\s*=|\b(?:href|src)\s*=/iu.test(svg)) throw new Error("Generated SVG failed the inert-output policy."); const viewBox = /viewBox="[^"]*?([\d.]+)\s+([\d.]+)"/u.exec(svg); const width = Number(viewBox?.[1] ?? /width="([\d.]+)"/u.exec(svg)?.[1] ?? 0); const height = Number( viewBox?.[2] ?? /height="([\d.]+)"/u.exec(svg)?.[1] ?? 0, ); return { svg, width, height, quietZone: options.format === "qrcode" ? "At least 4 modules on every side" : options.format === "datamatrix" ? "At least 1 module on every side" : "At least 10 narrow modules at left and right", }; } export interface BatchRow { name: string; value: string; } function parseCsvLine(line: string): string[] { const cells: string[] = []; let cell = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const character = line[index]!; if (quoted && character === '"' && line[index + 1] === '"') { cell += '"'; index += 1; } else if (character === '"') quoted = !quoted; else if (character === "," && !quoted) { cells.push(cell); cell = ""; } else cell += character; } if (quoted) throw new Error("A batch row contains an unclosed quote."); cells.push(cell); return cells; } export function parseBatch(source: string): BatchRow[] { if (source.length > 256 * 1024) throw new Error("Batch input exceeds 256 KiB."); const rows = source .replaceAll("\r\n", "\n") .split("\n") .filter((line) => line.trim()) .map((line, index) => { const [name, value, ...extra] = parseCsvLine(line); if (extra.length || !name?.trim() || value === undefined || !value.trim()) throw new Error(`Batch row ${index + 1} must contain name,value.`); return { name: name.trim(), value }; }); if (rows.length > 100) throw new Error("A batch is limited to 100 barcodes."); return rows; } export function createBarcodeZip( rows: BatchRow[], options: Omit, ): Uint8Array { const files: Record = {}; const used = new Set(); for (const [index, row] of rows.entries()) { let name = sanitizeDownloadFilename( row.name, `barcode-${index + 1}`, ).replace(/\.svg$/iu, ""); if (!name) name = `barcode-${index + 1}`; let unique = name; for (let counter = 2; used.has(unique.toLowerCase()); counter += 1) unique = `${name}-${counter}`; used.add(unique.toLowerCase()); files[`${unique}.svg`] = strToU8( renderBarcodeSvg({ ...options, text: row.value }).svg, ); } return zipSync(files, { level: 6, mtime: new Date("1980-01-01T00:00:00Z") }); }