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
35 changes: 30 additions & 5 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import type { LabelObject } from "../../registry";
import type { Gs1DatabarProps } from "../../registry/gs1databar";
import { objectRotation } from "../../registry/rotation";
import { dotsToPx } from "../../lib/coordinates";
import {
GS1_DATABAR_DEFAULT_SEGMENTS,
GS1_DATABAR_EXPANDED_SYMBOLOGIES,
gtin14WithCheck,
wrapGs1AIs,
} from "../../lib/gs1";
import { MICROPDF417_QUIET_ZONE_ROWS } from "./bwipConstants";

const GS1_DATABAR_BCID: Record<Gs1DatabarProps["symbology"], string> = {
1: "databaromni",
2: "databartruncated",
3: "databarstacked",
4: "databarstackedomni",
5: "databarlimited",
6: "databarexpanded",
7: "databarexpandedstacked",
};

const BCID: Partial<Record<LabelObject["type"], string>> = {
code128: "code128",
code39: "code39",
Expand All @@ -19,6 +36,9 @@ const BCID: Partial<Record<LabelObject["type"], string>> = {
logmars: "code39",
msi: "msi",
plessey: "plessey",
// Placeholder — the actual bcid is resolved per-symbology via
// GS1_DATABAR_BCID below. This entry exists only to pass the
// `if (!bcid) return null` guard at the top of buildBwipOptions.
gs1databar: "databaromni",
planet: "planet",
postal: "postnet",
Expand Down Expand Up @@ -260,15 +280,20 @@ export function buildBwipOptions(
}
case "gs1databar": {
const p = obj.props;
const raw = (p.content || "0").replace(/\D/g, "");
const padded = raw.padStart(13, "0").slice(0, 14);
const sym = p.symbology;
const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym);
// bwip-js needs (AI)data parens; canonical model stores raw digits.
// Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check.
const text = isExpanded
? wrapGs1AIs(p.content)
: `(01)${gtin14WithCheck(p.content)}`;
opts = {
bcid,
text: `(01)${padded}`,
bcid: GS1_DATABAR_BCID[sym],
text,
scale: scale1D,
height: 10,
// Adds 2 quiet-zone rows above and below so canvas height matches Labelary.
paddingheight: 2,
...(sym === 7 ? { segments: p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS } : {}),
};
break;
}
Expand Down
66 changes: 66 additions & 0 deletions src/lib/gs1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import {
gtin14WithCheck,
wrapGs1AIs,
GS1_DATABAR_EXPANDED_SYMBOLOGIES,
GS1_DATABAR_DEFAULT_SEGMENTS,
} from "./gs1";

describe("gtin14WithCheck", () => {
it("computes the check digit for a 13-digit body", () => {
// Known reference: GTIN-13 "0112345678901" → check 1 (verified empirically)
expect(gtin14WithCheck("0112345678901")).toBe("01123456789011");
});

it("pads short input to 13 digits before computing check", () => {
expect(gtin14WithCheck("12345")).toHaveLength(14);
});

it("returns the input unchanged when 14 digits are supplied", () => {
expect(gtin14WithCheck("01123456789011")).toBe("01123456789011");
});

it("strips an AI 01 prefix when input is longer than 14 digits", () => {
// "01" prefix + 14 digits → keep only the 14
expect(gtin14WithCheck("0101123456789011")).toBe("01123456789011");
});

it("ignores non-digit characters", () => {
expect(gtin14WithCheck("(01)12345")).toHaveLength(14);
});
});

describe("wrapGs1AIs", () => {
it("wraps raw AI 01 + GTIN-14 in parens", () => {
expect(wrapGs1AIs("0112345678901231")).toBe("(01)12345678901231");
});

it("auto-completes the GTIN check digit when AI 01 data is short", () => {
// 11 digits after "01" → padded to 13 + check = 14
const out = wrapGs1AIs("0112345678901");
expect(out.startsWith("(01)")).toBe(true);
expect(out.slice(4)).toHaveLength(14);
});

it("passes through already-parenthesised input unchanged", () => {
expect(wrapGs1AIs("(01)12345678901231")).toBe("(01)12345678901231");
});

it("appends unknown AI data verbatim so bwip-js surfaces the error", () => {
// AI 99 is not in the fixed-length table
expect(wrapGs1AIs("99abcdef")).toBe("99abcdef");
});
});

describe("constants", () => {
it("treats only 6 and 7 as Expanded variants", () => {
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(6)).toBe(true);
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(7)).toBe(true);
expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(1)).toBe(false);
});

it("uses the spec-maximum 22 as the Expanded Stacked segments default", () => {
expect(GS1_DATABAR_DEFAULT_SEGMENTS).toBe(22);
expect(GS1_DATABAR_DEFAULT_SEGMENTS % 2).toBe(0);
});
});
61 changes: 61 additions & 0 deletions src/lib/gs1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* GS1 Databar domain helpers: AI parsing, GTIN check digit, and shared constants.
*
* These live outside the rendering layer because the same logic is shared between
* ZPL generation, bwip-js content prep, and (future) input validation.
*/

/** Symbologies that accept free-form AI content (Expanded, Expanded Stacked). */
export const GS1_DATABAR_EXPANDED_SYMBOLOGIES: ReadonlySet<number> = new Set([6, 7]);

/** Spec-maximum segments-per-row for ^BR Expanded Stacked (must be even, 2–22). */
export const GS1_DATABAR_DEFAULT_SEGMENTS = 22;

/** Fixed-length GS1 Application Identifiers — used to wrap raw input in parens. */
const FIXED_AI_LEN: Record<string, number> = {
"00": 18, "01": 14, "02": 14, "11": 6, "13": 6, "15": 6, "17": 6, "20": 2,
};

/**
* Pad to 13 digits and append the GTIN-14 check digit. Used for symbologies 1–5
* where the user can supply a partial GTIN; bwip-js requires a fully-valid
* 14-digit number, while Labelary completes it server-side.
*/
export function gtin14WithCheck(content: string): string {
let digits = content.replace(/\D/g, "");
if (digits.startsWith("01") && digits.length > 14) digits = digits.slice(2);
if (digits.length >= 14) return digits.slice(0, 14);
const body = digits.padStart(13, "0");
let sum = 0;
for (let i = 0; i < 13; i++) {
sum += parseInt(body[12 - i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1);
}
return body + ((10 - (sum % 10)) % 10).toString();
}

/**
* Wrap a raw GS1 AI sequence (e.g. "0112345678901231") in parens for bwip-js
* (e.g. "(01)12345678901231"). Already-wrapped input passes through unchanged.
* Unknown AIs short-circuit and are appended verbatim so bwip-js can surface a
* helpful parser error.
*/
export function wrapGs1AIs(content: string): string {
if (content.includes("(")) return content;
let out = "";
let pos = 0;
while (pos < content.length) {
const ai = content.slice(pos, pos + 2);
const len = FIXED_AI_LEN[ai];
if (len === undefined) {
out += content.slice(pos);
break;
}
let data = content.slice(pos + 2, pos + 2 + len);
if (ai === "01" && data.length < 14 && /^\d+$/.test(data)) {
data = gtin14WithCheck(data);
}
out += `(${ai})${data}`;
pos += 2 + len;
}
return out;
}
28 changes: 25 additions & 3 deletions src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import type { EllipseProps } from "../registry/ellipse";
import type { LineProps } from "../registry/line";
import type { ImageProps } from "../registry/image";
import type { Barcode1DProps } from "../registry/barcode1d";
import type { Gs1DatabarProps } from "../registry/gs1databar";
import type { Pdf417Props } from "../registry/pdf417";
import type { SerialProps } from "../registry/serial";
import { isZplRotation, type ZplRotation } from "../registry/rotation";
import type { AztecProps } from "../registry/aztec";
import type { MicroPdf417Props } from "../registry/micropdf417";
import type { CodablockProps } from "../registry/codablock";
import { putImage } from "./imageCache";
import { GS1_DATABAR_DEFAULT_SEGMENTS } from "./gs1";

/**
* Categorised import report produced alongside the parsed objects.
Expand Down Expand Up @@ -215,6 +217,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
let bcInterp = true;
let bcCheck = false;
let bcRotation: ZplRotation = "N";
let gsSymbology: Gs1DatabarProps["symbology"] = 1;
let gsSegments: number | undefined = undefined;
// ^BY barcode defaults
let byModuleWidth = 2;
let byHeight = 0; // 0 = no ^BY height; barcode handlers use ||100 as sentinel
Expand Down Expand Up @@ -440,7 +444,6 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
case "logmars":
case "msi":
case "plessey":
case "gs1databar":
case "planet":
case "postal":
objects.push(
Expand All @@ -461,6 +464,24 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
),
);
break;
case "gs1databar":
objects.push(
makeObj(
"gs1databar",
x,
y,
{
content,
moduleWidth: byModuleWidth,
symbology: gsSymbology,
segments: gsSegments,
rotation: bcRotation,
} satisfies Gs1DatabarProps,
posType,
comment,
),
);
break;
case "pdf417":
objects.push(
makeObj(
Expand Down Expand Up @@ -691,12 +712,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
bcInterp = (p[3] ?? "Y") === "Y";
},
// GS1 Databar: different param layout, also updates byModuleWidth
// ^BRN,{symbology},{magnification},{separator},{height},{segments}
// ^BRo,{symbology},{magnification},{separator},{height},{segments}
BR(p) {
fieldType = "gs1databar";
bcRotation = readRotation(p[0]);
bcHeight = int(p[4], byHeight || 100);
byModuleWidth = int(p[2], byModuleWidth);
gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1;
gsSegments = p[5] !== undefined ? int(p[5], GS1_DATABAR_DEFAULT_SEGMENTS) : undefined;
},

// ^BQN,2,{magnification} — QR Code
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const ar = {
height: 'الارتفاع (نقاط)',
printInterpretation: 'مقروء',
moduleWidth: 'عرض الوحدة',
symbology: 'الرمزية',
segments: 'مقاطع لكل صف',
},
planet: {
content: 'المحتوى',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const bg = {
height: 'Височина (точки)',
printInterpretation: 'Четимо',
moduleWidth: 'Ширина на модул',
symbology: 'Символика',
segments: 'Сегменти на ред',
},
planet: {
content: 'Съдържание',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const cs = {
height: 'Výška (body)',
printInterpretation: 'Čitelný pro člověka',
moduleWidth: 'Šířka modulu',
symbology: 'Symbolika',
segments: 'Segmenty na řádek',
},
planet: {
content: 'Obsah',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const da = {
height: 'Højde (punkter)',
printInterpretation: 'Læsbar',
moduleWidth: 'Modulbredde',
symbology: 'Symbolik',
segments: 'Segmenter per række',
},
planet: {
content: 'Indhold',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ const de = {
height: 'Höhe (Punkte)',
printInterpretation: 'Klartext',
moduleWidth: 'Modulbreite',
symbology: 'Symbolik',
segments: 'Segmente pro Zeile',
},
planet: {
content: 'Inhalt',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const el = {
height: 'Ύψος (κουκκίδες)',
printInterpretation: 'Αναγνώσιμο',
moduleWidth: 'Πλάτος μονάδας',
symbology: 'Συμβολολογία',
segments: 'Τμήματα ανά γραμμή',
},
planet: {
content: 'Περιεχόμενο',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ const en = {
height: 'Height (dots)',
printInterpretation: 'Human readable',
moduleWidth: 'Module width',
symbology: 'Symbology',
segments: 'Segments per row',
},
planet: {
content: 'Content',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const es = {
height: 'Altura (puntos)',
printInterpretation: 'Legible',
moduleWidth: 'Ancho módulo',
symbology: 'Simbología',
segments: 'Segmentos por fila',
},
planet: {
content: 'Contenido',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const et = {
height: 'Kõrgus (punkti)',
printInterpretation: 'Loetav',
moduleWidth: 'Mooduli laius',
symbology: 'Sümbolika',
segments: 'Segmente rea kohta',
},
planet: {
content: 'Sisu',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const fa = {
height: 'ارتفاع (نقطه)',
printInterpretation: 'قابل خواندن',
moduleWidth: 'عرض ماژول',
symbology: 'نماد‌شناسی',
segments: 'بخش در هر ردیف',
},
planet: {
content: 'محتوا',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const fi = {
height: 'Korkeus (pistettä)',
printInterpretation: 'Luettava',
moduleWidth: 'Moduulin leveys',
symbology: 'Symboliikka',
segments: 'Segmentit per rivi',
},
planet: {
content: 'Sisältö',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const fr = {
height: 'Hauteur (points)',
printInterpretation: 'Lisible',
moduleWidth: 'Largeur module',
symbology: 'Symbologie',
segments: 'Segments par ligne',
},
planet: {
content: 'Contenu',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/he.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const he = {
height: 'גובה (נקודות)',
printInterpretation: 'קריא',
moduleWidth: 'רוחב מודול',
symbology: 'סימבולוגיה',
segments: 'מקטעים לשורה',
},
planet: {
content: 'תוכן',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/hr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const hr = {
height: 'Visina (točke)',
printInterpretation: 'Čitljivo za ljude',
moduleWidth: 'Širina modula',
symbology: 'Simbologija',
segments: 'Segmenti po retku',
},
planet: {
content: 'Sadržaj',
Expand Down
2 changes: 2 additions & 0 deletions src/locales/hu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ const hu = {
height: 'Magasság (pont)',
printInterpretation: 'Olvasható',
moduleWidth: 'Modulszélesség',
symbology: 'Szimbológia',
segments: 'Szegmens soronként',
},
planet: {
content: 'Tartalom',
Expand Down
Loading