156 lines
4.5 KiB
TypeScript
156 lines
4.5 KiB
TypeScript
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<BarcodeFormat>([
|
||
"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<BarcodeOptions, "text">,
|
||
): Uint8Array {
|
||
const files: Record<string, Uint8Array> = {};
|
||
const used = new Set<string>();
|
||
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") });
|
||
}
|