Skip to content

Commit dd05902

Browse files
authored
Merge pull request #21 from u8array/feature/gs1-databar-variants
Feature/gs1 databar variants
2 parents 8288416 + e7b22a5 commit dd05902

48 files changed

Lines changed: 518 additions & 33 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/components/Canvas/bwipHelpers.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
import type { LabelObject } from "../../registry";
2+
import type { Gs1DatabarProps } from "../../registry/gs1databar";
23
import { objectRotation } from "../../registry/rotation";
34
import { dotsToPx } from "../../lib/coordinates";
5+
import {
6+
GS1_DATABAR_DEFAULT_SEGMENTS,
7+
GS1_DATABAR_EXPANDED_SYMBOLOGIES,
8+
gtin14WithCheck,
9+
wrapGs1AIs,
10+
} from "../../lib/gs1";
411
import { MICROPDF417_QUIET_ZONE_ROWS } from "./bwipConstants";
512

13+
const GS1_DATABAR_BCID: Record<Gs1DatabarProps["symbology"], string> = {
14+
1: "databaromni",
15+
2: "databartruncated",
16+
3: "databarstacked",
17+
4: "databarstackedomni",
18+
5: "databarlimited",
19+
6: "databarexpanded",
20+
7: "databarexpandedstacked",
21+
};
22+
623
const BCID: Partial<Record<LabelObject["type"], string>> = {
724
code128: "code128",
825
code39: "code39",
@@ -19,6 +36,9 @@ const BCID: Partial<Record<LabelObject["type"], string>> = {
1936
logmars: "code39",
2037
msi: "msi",
2138
plessey: "plessey",
39+
// Placeholder — the actual bcid is resolved per-symbology via
40+
// GS1_DATABAR_BCID below. This entry exists only to pass the
41+
// `if (!bcid) return null` guard at the top of buildBwipOptions.
2242
gs1databar: "databaromni",
2343
planet: "planet",
2444
postal: "postnet",
@@ -260,15 +280,20 @@ export function buildBwipOptions(
260280
}
261281
case "gs1databar": {
262282
const p = obj.props;
263-
const raw = (p.content || "0").replace(/\D/g, "");
264-
const padded = raw.padStart(13, "0").slice(0, 14);
283+
const sym = p.symbology;
284+
const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym);
285+
// bwip-js needs (AI)data parens; canonical model stores raw digits.
286+
// Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check.
287+
const text = isExpanded
288+
? wrapGs1AIs(p.content)
289+
: `(01)${gtin14WithCheck(p.content)}`;
265290
opts = {
266-
bcid,
267-
text: `(01)${padded}`,
291+
bcid: GS1_DATABAR_BCID[sym],
292+
text,
268293
scale: scale1D,
269294
height: 10,
270-
// Adds 2 quiet-zone rows above and below so canvas height matches Labelary.
271295
paddingheight: 2,
296+
...(sym === 7 ? { segments: p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS } : {}),
272297
};
273298
break;
274299
}

src/lib/gs1.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
gtin14WithCheck,
4+
wrapGs1AIs,
5+
GS1_DATABAR_EXPANDED_SYMBOLOGIES,
6+
GS1_DATABAR_DEFAULT_SEGMENTS,
7+
} from "./gs1";
8+
9+
describe("gtin14WithCheck", () => {
10+
it("computes the check digit for a 13-digit body", () => {
11+
// Known reference: GTIN-13 "0112345678901" → check 1 (verified empirically)
12+
expect(gtin14WithCheck("0112345678901")).toBe("01123456789011");
13+
});
14+
15+
it("pads short input to 13 digits before computing check", () => {
16+
expect(gtin14WithCheck("12345")).toHaveLength(14);
17+
});
18+
19+
it("returns the input unchanged when 14 digits are supplied", () => {
20+
expect(gtin14WithCheck("01123456789011")).toBe("01123456789011");
21+
});
22+
23+
it("strips an AI 01 prefix when input is longer than 14 digits", () => {
24+
// "01" prefix + 14 digits → keep only the 14
25+
expect(gtin14WithCheck("0101123456789011")).toBe("01123456789011");
26+
});
27+
28+
it("ignores non-digit characters", () => {
29+
expect(gtin14WithCheck("(01)12345")).toHaveLength(14);
30+
});
31+
});
32+
33+
describe("wrapGs1AIs", () => {
34+
it("wraps raw AI 01 + GTIN-14 in parens", () => {
35+
expect(wrapGs1AIs("0112345678901231")).toBe("(01)12345678901231");
36+
});
37+
38+
it("auto-completes the GTIN check digit when AI 01 data is short", () => {
39+
// 11 digits after "01" → padded to 13 + check = 14
40+
const out = wrapGs1AIs("0112345678901");
41+
expect(out.startsWith("(01)")).toBe(true);
42+
expect(out.slice(4)).toHaveLength(14);
43+
});
44+
45+
it("passes through already-parenthesised input unchanged", () => {
46+
expect(wrapGs1AIs("(01)12345678901231")).toBe("(01)12345678901231");
47+
});
48+
49+
it("appends unknown AI data verbatim so bwip-js surfaces the error", () => {
50+
// AI 99 is not in the fixed-length table
51+
expect(wrapGs1AIs("99abcdef")).toBe("99abcdef");
52+
});
53+
});
54+
55+
describe("constants", () => {
56+
it("treats only 6 and 7 as Expanded variants", () => {
57+
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(6)).toBe(true);
58+
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(7)).toBe(true);
59+
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(1)).toBe(false);
60+
});
61+
62+
it("uses the spec-maximum 22 as the Expanded Stacked segments default", () => {
63+
expect(GS1_DATABAR_DEFAULT_SEGMENTS).toBe(22);
64+
expect(GS1_DATABAR_DEFAULT_SEGMENTS % 2).toBe(0);
65+
});
66+
});

src/lib/gs1.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* GS1 Databar domain helpers: AI parsing, GTIN check digit, and shared constants.
3+
*
4+
* These live outside the rendering layer because the same logic is shared between
5+
* ZPL generation, bwip-js content prep, and (future) input validation.
6+
*/
7+
8+
/** Symbologies that accept free-form AI content (Expanded, Expanded Stacked). */
9+
export const GS1_DATABAR_EXPANDED_SYMBOLOGIES: ReadonlySet<number> = new Set([6, 7]);
10+
11+
/** Spec-maximum segments-per-row for ^BR Expanded Stacked (must be even, 2–22). */
12+
export const GS1_DATABAR_DEFAULT_SEGMENTS = 22;
13+
14+
/** Fixed-length GS1 Application Identifiers — used to wrap raw input in parens. */
15+
const FIXED_AI_LEN: Record<string, number> = {
16+
"00": 18, "01": 14, "02": 14, "11": 6, "13": 6, "15": 6, "17": 6, "20": 2,
17+
};
18+
19+
/**
20+
* Pad to 13 digits and append the GTIN-14 check digit. Used for symbologies 1–5
21+
* where the user can supply a partial GTIN; bwip-js requires a fully-valid
22+
* 14-digit number, while Labelary completes it server-side.
23+
*/
24+
export function gtin14WithCheck(content: string): string {
25+
let digits = content.replace(/\D/g, "");
26+
if (digits.startsWith("01") && digits.length > 14) digits = digits.slice(2);
27+
if (digits.length >= 14) return digits.slice(0, 14);
28+
const body = digits.padStart(13, "0");
29+
let sum = 0;
30+
for (let i = 0; i < 13; i++) {
31+
sum += parseInt(body[12 - i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1);
32+
}
33+
return body + ((10 - (sum % 10)) % 10).toString();
34+
}
35+
36+
/**
37+
* Wrap a raw GS1 AI sequence (e.g. "0112345678901231") in parens for bwip-js
38+
* (e.g. "(01)12345678901231"). Already-wrapped input passes through unchanged.
39+
* Unknown AIs short-circuit and are appended verbatim so bwip-js can surface a
40+
* helpful parser error.
41+
*/
42+
export function wrapGs1AIs(content: string): string {
43+
if (content.includes("(")) return content;
44+
let out = "";
45+
let pos = 0;
46+
while (pos < content.length) {
47+
const ai = content.slice(pos, pos + 2);
48+
const len = FIXED_AI_LEN[ai];
49+
if (len === undefined) {
50+
out += content.slice(pos);
51+
break;
52+
}
53+
let data = content.slice(pos + 2, pos + 2 + len);
54+
if (ai === "01" && data.length < 14 && /^\d+$/.test(data)) {
55+
data = gtin14WithCheck(data);
56+
}
57+
out += `(${ai})${data}`;
58+
pos += 2 + len;
59+
}
60+
return out;
61+
}

src/lib/zplParser.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import type { EllipseProps } from "../registry/ellipse";
1111
import type { LineProps } from "../registry/line";
1212
import type { ImageProps } from "../registry/image";
1313
import type { Barcode1DProps } from "../registry/barcode1d";
14+
import type { Gs1DatabarProps } from "../registry/gs1databar";
1415
import type { Pdf417Props } from "../registry/pdf417";
1516
import type { SerialProps } from "../registry/serial";
1617
import { isZplRotation, type ZplRotation } from "../registry/rotation";
1718
import type { AztecProps } from "../registry/aztec";
1819
import type { MicroPdf417Props } from "../registry/micropdf417";
1920
import type { CodablockProps } from "../registry/codablock";
2021
import { putImage } from "./imageCache";
22+
import { GS1_DATABAR_DEFAULT_SEGMENTS } from "./gs1";
2123

2224
/**
2325
* Categorised import report produced alongside the parsed objects.
@@ -215,6 +217,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
215217
let bcInterp = true;
216218
let bcCheck = false;
217219
let bcRotation: ZplRotation = "N";
220+
let gsSymbology: Gs1DatabarProps["symbology"] = 1;
221+
let gsSegments: number | undefined = undefined;
218222
// ^BY barcode defaults
219223
let byModuleWidth = 2;
220224
let byHeight = 0; // 0 = no ^BY height; barcode handlers use ||100 as sentinel
@@ -440,7 +444,6 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
440444
case "logmars":
441445
case "msi":
442446
case "plessey":
443-
case "gs1databar":
444447
case "planet":
445448
case "postal":
446449
objects.push(
@@ -461,6 +464,24 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
461464
),
462465
);
463466
break;
467+
case "gs1databar":
468+
objects.push(
469+
makeObj(
470+
"gs1databar",
471+
x,
472+
y,
473+
{
474+
content,
475+
moduleWidth: byModuleWidth,
476+
symbology: gsSymbology,
477+
segments: gsSegments,
478+
rotation: bcRotation,
479+
} satisfies Gs1DatabarProps,
480+
posType,
481+
comment,
482+
),
483+
);
484+
break;
464485
case "pdf417":
465486
objects.push(
466487
makeObj(
@@ -691,12 +712,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
691712
bcInterp = (p[3] ?? "Y") === "Y";
692713
},
693714
// GS1 Databar: different param layout, also updates byModuleWidth
694-
// ^BRN,{symbology},{magnification},{separator},{height},{segments}
715+
// ^BRo,{symbology},{magnification},{separator},{height},{segments}
695716
BR(p) {
696717
fieldType = "gs1databar";
697718
bcRotation = readRotation(p[0]);
698-
bcHeight = int(p[4], byHeight || 100);
699719
byModuleWidth = int(p[2], byModuleWidth);
720+
gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1;
721+
gsSegments = p[5] !== undefined ? int(p[5], GS1_DATABAR_DEFAULT_SEGMENTS) : undefined;
700722
},
701723

702724
// ^BQN,2,{magnification} — QR Code

src/locales/ar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ const ar = {
305305
height: 'الارتفاع (نقاط)',
306306
printInterpretation: 'مقروء',
307307
moduleWidth: 'عرض الوحدة',
308+
symbology: 'الرمزية',
309+
segments: 'مقاطع لكل صف',
308310
},
309311
planet: {
310312
content: 'المحتوى',

src/locales/bg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ const bg = {
305305
height: 'Височина (точки)',
306306
printInterpretation: 'Четимо',
307307
moduleWidth: 'Ширина на модул',
308+
symbology: 'Символика',
309+
segments: 'Сегменти на ред',
308310
},
309311
planet: {
310312
content: 'Съдържание',

src/locales/cs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ const cs = {
305305
height: 'Výška (body)',
306306
printInterpretation: 'Čitelný pro člověka',
307307
moduleWidth: 'Šířka modulu',
308+
symbology: 'Symbolika',
309+
segments: 'Segmenty na řádek',
308310
},
309311
planet: {
310312
content: 'Obsah',

src/locales/da.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ const da = {
305305
height: 'Højde (punkter)',
306306
printInterpretation: 'Læsbar',
307307
moduleWidth: 'Modulbredde',
308+
symbology: 'Symbolik',
309+
segments: 'Segmenter per række',
308310
},
309311
planet: {
310312
content: 'Indhold',

src/locales/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ const de = {
325325
height: 'Höhe (Punkte)',
326326
printInterpretation: 'Klartext',
327327
moduleWidth: 'Modulbreite',
328+
symbology: 'Symbolik',
329+
segments: 'Segmente pro Zeile',
328330
},
329331
planet: {
330332
content: 'Inhalt',

src/locales/el.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ const el = {
305305
height: 'Ύψος (κουκκίδες)',
306306
printInterpretation: 'Αναγνώσιμο',
307307
moduleWidth: 'Πλάτος μονάδας',
308+
symbology: 'Συμβολολογία',
309+
segments: 'Τμήματα ανά γραμμή',
308310
},
309311
planet: {
310312
content: 'Περιεχόμενο',

0 commit comments

Comments
 (0)