Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
33 changes: 22 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/x1ee7/barcode-core/actions/workflows/ci.yml/badge.svg)](https://github.com/x1ee7/barcode-core/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@x1ee7/barcode-core.svg)](https://www.npmjs.com/package/@x1ee7/barcode-core)
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4- db61a2)](https://github.com/sponsors/x1ee7)
[![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4-db61a2)](https://github.com/sponsors/x1ee7)

**Zero-dependency UPC / EAN / ISBN / ITF-14 barcode check-digit math,
validation, UPC-E expansion, and random / sequential code generation for
Expand Down Expand Up @@ -46,15 +46,16 @@ computeCheckDigit("ean13", "978013110362"); // → "9780131103627"
validateBarcodeText("upca", "036000291452"); // → { ok: true }
validateBarcodeText("upca", "036000291450"); // → { ok: false, error: "invalid check digit — expected 2, got 0" }

// UPC-E (6-digit body + number system) → 11-digit UPC-A payload
upcEExpand("0", "123455"); // → "01234500005"
// UPC-E (number system + 6-digit body); check digit comes from the UPC-A expansion
computeCheckDigit("upce", "0123456"); // → "01234565"
upcEExpand("0", "123455"); // → "01234500005"

// One random, structurally valid code
generateRandom("ean13"); // → e.g. "4006381333931"

// A contiguous run sharing a prefix, each with a valid check digit
buildSequential("ean13", "978", 1, 3);
// → ["9780000000012", "9780000000029", "9780000000036"]
// → ["9780000000019", "9780000000026", "9780000000033"]
```

## How the GS1 mod-10 check digit works
Expand All @@ -70,27 +71,37 @@ checkDigit = (10 − (weightedSum mod 10)) mod 10

`computeCheckDigit(format, payload)` applies the correct positional weights
for each format (exposed as `SPEC`) and returns the full code with the check
digit appended. `validateBarcodeText` recomputes it and also enforces the
expected digit count for fixed-length formats.
digit appended. Spaces and hyphens are ignored, short payloads are
left-padded with zeros, and anything else (letters, too many digits) throws.
`validateBarcodeText` recomputes the check digit and also enforces the
expected digit count, UPC-E number system 0/1, and the ISBN-13 978/979
prefix.

UPC-E is the one exception to the positional weights: per GS1, its check
digit is computed on the expanded UPC-A form, which `computeCheckDigit` and
`validateBarcodeText` handle for you.

## Supported formats

**Fixed-length** (have a computable check digit):
`upca`, `upce`, `ean13`, `ean8`, `isbn`, `itf14`

**Variable-length** (no fixed check digit — validation always passes):
**Variable-length** (no check digit — validation only rejects empty text,
and non-ASCII text for `code128` / `fnsku`):
`code128`, `datamatrix`, `fnsku`

Every function throws a descriptive error for an unknown format string.

## API

| Export | Description |
| --- | --- |
| `computeCheckDigit(format, payload)` | Returns payload + GS1 mod-10 check digit. Throws for variable-length formats. |
| `validateBarcodeText(format, text)` | `{ ok: true }` or `{ ok: false, error }`. Variable-length formats always pass. |
| `computeCheckDigit(format, payload)` | Returns payload + GS1 mod-10 check digit. Throws for variable-length formats, non-digits, or a payload longer than the format allows. |
| `validateBarcodeText(format, text)` | `{ ok: true }` or `{ ok: false, error }`. |
| `normalizeText(format, text)` | Upper-cases except Data Matrix (case-sensitive). |
| `upcEExpand(numSystem, comp)` | Expands a UPC-E body to its 11-digit UPC-A payload. |
| `upcEExpand(numSystem, comp)` | Expands a UPC-E body to its 11-digit UPC-A payload. Throws unless `numSystem` is 0/1 and `comp` is 6 digits. |
| `generateRandom(format)` | One random, structurally valid code. |
| `buildSequential(format, prefix, start, count)` | Contiguous run of check-digit-terminated codes. |
| `buildSequential(format, prefix, start, count)` | Contiguous run of check-digit-terminated codes. Throws up front on overflow, bad `start`/`count`, or a prefix that can't yield valid codes. |
| `isVariableLength(format)` / `isCaseSensitive(format)` | Format predicates. |
| `SPEC` | Payload length + positional weights per fixed-length format. |

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 11 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@x1ee7/barcode-core",
"version": "0.1.0",
"version": "0.2.0",
"description": "Zero-dependency UPC / EAN / ISBN / ITF-14 check-digit math, validation, UPC-E expansion, and random/sequential code generation. Powers upcgen.com in production.",
"keywords": [
"barcode",
Expand Down Expand Up @@ -33,9 +33,14 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"files": [
Expand All @@ -45,8 +50,9 @@
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"test:watch": "vitest",
"prepublishOnly": "npm run test && npm run build"
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
},
"devDependencies": {
"tsup": "^8.3.5",
Expand Down
36 changes: 29 additions & 7 deletions src/checkdigit.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
import { type BarcodeFormat, SPEC } from "./formats.js";
import { type BarcodeFormat, SPEC, assertFormat, extractDigits } from "./formats.js";
import { upcEExpand } from "./upce.js";

function mod10(digits: string, weights: number[]): string {
let sum = 0;
for (let i = 0; i < digits.length; i++) sum += parseInt(digits[i]!, 10) * weights[i]!;
return ((10 - (sum % 10)) % 10).toString();
}

/**
* Compute the GS1 modulo-10 check digit and return the full code
* (payload + check digit). Throws for variable-length formats, which have
* no check digit.
* (payload + check digit). Spaces and hyphens are ignored and short payloads
* are left-padded with zeros. Throws for variable-length formats (which have
* no check digit), for non-digit characters, and for payloads longer than the
* format allows (pass the payload *without* its check digit).
*
* UPC-E is checked the GS1 way: the 7-digit payload (number system + 6-digit
* body) is expanded to UPC-A and the check digit is taken from that.
*/
export function computeCheckDigit(format: BarcodeFormat, payload: string): string {
assertFormat(format);
const spec = SPEC[format];
if (!spec) throw new Error(`${format} has no check digit (variable-length)`);
const digits = payload.replace(/\D/g, "").padStart(spec.payloadLen, "0").slice(0, spec.payloadLen);
let sum = 0;
for (let i = 0; i < digits.length; i++) sum += parseInt(digits[i]!, 10) * spec.weights[i]!;
return digits + ((10 - (sum % 10)) % 10).toString();
const raw = extractDigits(payload, `${format} payload`);
if (raw.length === 0) throw new Error(`${format} payload has no digits`);
if (raw.length > spec.payloadLen) {
throw new Error(
`${format} payload must be at most ${spec.payloadLen} digits (got ${raw.length}); omit the check digit`,
);
}
const digits = raw.padStart(spec.payloadLen, "0");
if (format === "upce") {
const upca = SPEC.upca!;
return digits + mod10(upcEExpand(digits[0]!, digits.slice(1)), upca.weights);
}
return digits + mod10(digits, spec.weights);
}
27 changes: 27 additions & 0 deletions src/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,33 @@ export type BarcodeFormat =
| "datamatrix"
| "fnsku";

/** Every supported format, in declaration order. */
const FORMATS: ReadonlySet<string> = new Set<BarcodeFormat>([
"upca", "upce", "ean13", "ean8", "isbn", "itf14", "code128", "datamatrix", "fnsku",
]);

/**
* Throw a descriptive error for an unknown format. Guards plain-JS callers
* against typos like "UPCA" silently validating or crashing.
*/
export function assertFormat(format: unknown): asserts format is BarcodeFormat {
if (typeof format !== "string" || !FORMATS.has(format)) {
throw new Error(`unknown barcode format '${String(format)}' (expected one of: ${[...FORMATS].join(", ")})`);
}
}

/**
* Strip the separators people commonly type into numeric codes (spaces and
* hyphens) and return the bare digits. Throws on anything else, so stray
* letters are never silently discarded.
*/
export function extractDigits(text: string, what: string): string {
if (typeof text !== "string") throw new Error(`${what} must be a string (got ${typeof text})`);
const bad = text.match(/[^\d\s-]/);
if (bad) throw new Error(`${what} may only contain digits, spaces, and hyphens (found '${bad[0]}')`);
return text.replace(/\D/g, "");
}

const VARIABLE_LENGTH: Set<BarcodeFormat> = new Set(["code128", "datamatrix", "fnsku"]);

/** True for symbologies that carry an arbitrary-length payload (no check digit). */
Expand Down
76 changes: 39 additions & 37 deletions src/generators.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,73 @@
import { type BarcodeFormat, SPEC, isVariableLength } from "./formats.js";
import { type BarcodeFormat, SPEC, assertFormat, extractDigits, isVariableLength } from "./formats.js";
import { computeCheckDigit } from "./checkdigit.js";
import { validateBarcodeText } from "./validate.js";

/**
* Expand a 6-digit UPC-E compressed body (plus number system) into its
* 11-digit UPC-A payload, following the GS1 zero-suppression rules.
*/
export function upcEExpand(numSystem: string, comp: string): string {
const last = comp[5]!;
if (last >= "0" && last <= "2") return numSystem + comp.slice(0, 2) + last + "0000" + comp.slice(2, 5);
if (last === "3") return numSystem + comp.slice(0, 3) + "00000" + comp.slice(3, 5);
if (last === "4") return numSystem + comp.slice(0, 4) + "00000" + comp[4]!;
return numSystem + comp.slice(0, 5) + "0000" + last;
export { upcEExpand } from "./upce.js";

const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

function randomDigits(n: number): string {
let out = "";
for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 10).toString();
return out;
}

/** Generate a single random, structurally valid code for the given format. */
export function generateRandom(format: BarcodeFormat): string {
if (format === "fnsku") {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let out = "X00";
for (let i = 0; i < 7; i++) out += chars[Math.floor(Math.random() * chars.length)];
return out;
}
assertFormat(format);
if (isVariableLength(format)) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let out = "";
for (let i = 0; i < 10; i++) out += chars[Math.floor(Math.random() * chars.length)];
let out = format === "fnsku" ? "X00" : "";
const len = format === "fnsku" ? 7 : 10;
for (let i = 0; i < len; i++) out += ALNUM[Math.floor(Math.random() * ALNUM.length)];
return out;
}
if (format === "upce") {
const ns = Math.random() < 0.5 ? "0" : "1";
let comp = "";
for (let i = 0; i < 6; i++) comp += Math.floor(Math.random() * 10).toString();
const upca11 = upcEExpand(ns, comp);
let sum = 0;
for (let i = 0; i < 11; i++) sum += parseInt(upca11[i]!, 10) * (i % 2 === 0 ? 3 : 1);
const check = ((10 - (sum % 10)) % 10).toString();
return ns + comp + check;
return computeCheckDigit(format, (Math.random() < 0.5 ? "0" : "1") + randomDigits(6));
}
const spec = SPEC[format]!;
let payload = format === "isbn" ? "978" : "";
while (payload.length < spec.payloadLen) payload += Math.floor(Math.random() * 10).toString();
return computeCheckDigit(format, payload);
const prefix = format === "isbn" ? (Math.random() < 0.5 ? "978" : "979") : "";
return computeCheckDigit(format, prefix + randomDigits(spec.payloadLen - prefix.length));
}

/**
* Build a contiguous run of `count` codes from a shared prefix, starting at
* `start`, each terminated with a valid check digit. Throws for
* variable-length formats or an over-long prefix.
* variable-length formats, an over-long or non-numeric prefix, a negative or
* non-integer `start`/`count`, a run that would overflow the available
* digits, or a prefix that yields invalid codes (e.g. UPC-E number system
* other than 0/1, ISBN not starting 978/979).
*/
export function buildSequential(
format: BarcodeFormat,
prefix: string,
start: number,
count: number,
): string[] {
assertFormat(format);
if (isVariableLength(format)) {
throw new Error(`sequential mode is not supported for ${format}`);
}
if (!Number.isSafeInteger(start) || start < 0) {
throw new Error(`start must be a non-negative integer (got ${start})`);
}
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(`count must be a non-negative integer (got ${count})`);
}
const spec = SPEC[format]!;
const cleanPrefix = prefix.replace(/\D/g, "");
const cleanPrefix = extractDigits(prefix, "prefix");
const suffixLen = spec.payloadLen - cleanPrefix.length;
if (suffixLen < 1) throw new Error(`prefix '${prefix}' too long for ${format} (max ${spec.payloadLen - 1})`);
if (suffixLen < 1) throw new Error(`prefix '${prefix}' too long for ${format} (max ${spec.payloadLen - 1} digits)`);
if (count > 0 && (start + count - 1).toString().length > suffixLen) {
throw new Error(
`sequential overflow: ${start + count - 1} does not fit in ${suffixLen} digit(s) after prefix '${prefix}'`,
);
}
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const suffix = (start + i).toString().padStart(suffixLen, "0");
if (suffix.length > suffixLen) throw new Error("sequential overflow");
codes.push(computeCheckDigit(format, cleanPrefix + suffix));
const code = computeCheckDigit(format, cleanPrefix + (start + i).toString().padStart(suffixLen, "0"));
const check = validateBarcodeText(format, code);
if (!check.ok) throw new Error(`prefix '${prefix}' produces invalid ${format} codes: ${check.error}`);
codes.push(code);
}
return codes;
}
18 changes: 18 additions & 0 deletions src/upce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Expand a 6-digit UPC-E compressed body (plus number system) into its
* 11-digit UPC-A payload, following the GS1 zero-suppression rules.
* Throws unless `numSystem` is "0" or "1" and `comp` is exactly 6 digits.
*/
export function upcEExpand(numSystem: string, comp: string): string {
if (numSystem !== "0" && numSystem !== "1") {
throw new Error(`UPC-E number system must be 0 or 1 (got '${numSystem}')`);
}
if (!/^\d{6}$/.test(comp)) {
throw new Error(`UPC-E body must be exactly 6 digits (got '${comp}')`);
}
const last = comp[5]!;
if (last >= "0" && last <= "2") return numSystem + comp.slice(0, 2) + last + "0000" + comp.slice(2, 5);
if (last === "3") return numSystem + comp.slice(0, 3) + "00000" + comp.slice(3, 5);
if (last === "4") return numSystem + comp.slice(0, 4) + "00000" + comp[4]!;
return numSystem + comp.slice(0, 5) + "0000" + last;
}
Loading
Loading