diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 0b161aac..7b8aaa4d 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -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 = { + 1: "databaromni", + 2: "databartruncated", + 3: "databarstacked", + 4: "databarstackedomni", + 5: "databarlimited", + 6: "databarexpanded", + 7: "databarexpandedstacked", +}; + const BCID: Partial> = { code128: "code128", code39: "code39", @@ -19,6 +36,9 @@ const BCID: Partial> = { 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", @@ -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; } diff --git a/src/lib/gs1.test.ts b/src/lib/gs1.test.ts new file mode 100644 index 00000000..d495e623 --- /dev/null +++ b/src/lib/gs1.test.ts @@ -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); + }); +}); diff --git a/src/lib/gs1.ts b/src/lib/gs1.ts new file mode 100644 index 00000000..d6534f4f --- /dev/null +++ b/src/lib/gs1.ts @@ -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 = 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 = { + "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; +} diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 9f5732a1..078b6d22 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -11,6 +11,7 @@ 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"; @@ -18,6 +19,7 @@ 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. @@ -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 @@ -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( @@ -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( @@ -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 diff --git a/src/locales/ar.ts b/src/locales/ar.ts index badfa140..f5da316e 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -305,6 +305,8 @@ const ar = { height: 'الارتفاع (نقاط)', printInterpretation: 'مقروء', moduleWidth: 'عرض الوحدة', + symbology: 'الرمزية', + segments: 'مقاطع لكل صف', }, planet: { content: 'المحتوى', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 5617cd9d..799b2079 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -305,6 +305,8 @@ const bg = { height: 'Височина (точки)', printInterpretation: 'Четимо', moduleWidth: 'Ширина на модул', + symbology: 'Символика', + segments: 'Сегменти на ред', }, planet: { content: 'Съдържание', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index b993ccfa..1df1d3e9 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -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', diff --git a/src/locales/da.ts b/src/locales/da.ts index 04df6379..b554b5bc 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -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', diff --git a/src/locales/de.ts b/src/locales/de.ts index 9a6d3d3b..0347dded 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -325,6 +325,8 @@ const de = { height: 'Höhe (Punkte)', printInterpretation: 'Klartext', moduleWidth: 'Modulbreite', + symbology: 'Symbolik', + segments: 'Segmente pro Zeile', }, planet: { content: 'Inhalt', diff --git a/src/locales/el.ts b/src/locales/el.ts index f07a3982..5c73c04b 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -305,6 +305,8 @@ const el = { height: 'Ύψος (κουκκίδες)', printInterpretation: 'Αναγνώσιμο', moduleWidth: 'Πλάτος μονάδας', + symbology: 'Συμβολολογία', + segments: 'Τμήματα ανά γραμμή', }, planet: { content: 'Περιεχόμενο', diff --git a/src/locales/en.ts b/src/locales/en.ts index 8a6b062e..add2b87a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -325,6 +325,8 @@ const en = { height: 'Height (dots)', printInterpretation: 'Human readable', moduleWidth: 'Module width', + symbology: 'Symbology', + segments: 'Segments per row', }, planet: { content: 'Content', diff --git a/src/locales/es.ts b/src/locales/es.ts index a7caccee..328dc1b5 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/et.ts b/src/locales/et.ts index 55bc7e81..9e4fcff0 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -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', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 3756cb90..cff7a944 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -305,6 +305,8 @@ const fa = { height: 'ارتفاع (نقطه)', printInterpretation: 'قابل خواندن', moduleWidth: 'عرض ماژول', + symbology: 'نماد‌شناسی', + segments: 'بخش در هر ردیف', }, planet: { content: 'محتوا', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 2b797f9c..d7b76289 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -305,6 +305,8 @@ const fi = { height: 'Korkeus (pistettä)', printInterpretation: 'Luettava', moduleWidth: 'Moduulin leveys', + symbology: 'Symboliikka', + segments: 'Segmentit per rivi', }, planet: { content: 'Sisältö', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 0a2a1aa8..bff24116 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -305,6 +305,8 @@ const fr = { height: 'Hauteur (points)', printInterpretation: 'Lisible', moduleWidth: 'Largeur module', + symbology: 'Symbologie', + segments: 'Segments par ligne', }, planet: { content: 'Contenu', diff --git a/src/locales/he.ts b/src/locales/he.ts index 01fa5690..fbcce6e8 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -305,6 +305,8 @@ const he = { height: 'גובה (נקודות)', printInterpretation: 'קריא', moduleWidth: 'רוחב מודול', + symbology: 'סימבולוגיה', + segments: 'מקטעים לשורה', }, planet: { content: 'תוכן', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 523bed75..9c9b4e7b 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -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', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index ee37c2b9..243c1b97 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -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', diff --git a/src/locales/it.ts b/src/locales/it.ts index e82bb9d1..ffe40947 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -305,6 +305,8 @@ const it = { height: 'Altezza (punti)', printInterpretation: 'Leggibile', moduleWidth: 'Larghezza modulo', + symbology: 'Simbologia', + segments: 'Segmenti per riga', }, planet: { content: 'Contenuto', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 680a8a62..5e3dffc1 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -305,6 +305,8 @@ const ja = { height: '高さ(ドット)', printInterpretation: '可読文字', moduleWidth: 'モジュール幅', + symbology: 'シンボル体系', + segments: '1行あたりのセグメント数', }, planet: { content: '内容', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 838d96bc..eb286deb 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -305,6 +305,8 @@ const ko = { height: '높이 (도트)', printInterpretation: '판독 가능', moduleWidth: '모듈 폭', + symbology: '심볼로지', + segments: '행당 세그먼트', }, planet: { content: '내용', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index fa3ffec2..a59b1621 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -305,6 +305,8 @@ const lt = { height: 'Aukštis (taškai)', printInterpretation: 'Skaitomas', moduleWidth: 'Modulio plotis', + symbology: 'Simbolika', + segments: 'Segmentai eilutėje', }, planet: { content: 'Turinys', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 0ad3d3c2..845d3627 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -305,6 +305,8 @@ const lv = { height: 'Augstums (punkti)', printInterpretation: 'Lasāms', moduleWidth: 'Moduļa platums', + symbology: 'Simboloģija', + segments: 'Segmenti uz rindu', }, planet: { content: 'Saturs', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index f30fa473..586dd2ec 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -305,6 +305,8 @@ const nl = { height: 'Hoogte (punten)', printInterpretation: 'Leesbaar', moduleWidth: 'Modulebreedte', + symbology: 'Symbologie', + segments: 'Segmenten per rij', }, planet: { content: 'Inhoud', diff --git a/src/locales/no.ts b/src/locales/no.ts index 22ba9011..faf571eb 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -305,6 +305,8 @@ const no = { height: 'Høyde (punkter)', printInterpretation: 'Lesbar', moduleWidth: 'Modulbredde', + symbology: 'Symbolikk', + segments: 'Segmenter per rad', }, planet: { content: 'Innhold', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index b08f47f9..c6375bac 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -305,6 +305,8 @@ const pl = { height: 'Wysokość (punkty)', printInterpretation: 'Czytelny', moduleWidth: 'Szerokość modułu', + symbology: 'Symbolika', + segments: 'Segmenty na wiersz', }, planet: { content: 'Zawartość', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 13d244ae..764af39b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -305,6 +305,8 @@ const pt = { height: 'Altura (pontos)', printInterpretation: 'Legível', moduleWidth: 'Largura módulo', + symbology: 'Simbologia', + segments: 'Segmentos por linha', }, planet: { content: 'Conteúdo', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 3d8657c1..d8e8a132 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -305,6 +305,8 @@ const ro = { height: 'Înălțime (puncte)', printInterpretation: 'Lizibil', moduleWidth: 'Lățime modul', + symbology: 'Simbologie', + segments: 'Segmente pe rând', }, planet: { content: 'Conținut', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 171baccf..1b683dc2 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -305,6 +305,8 @@ const sk = { height: 'Výška (body)', printInterpretation: 'Čitateľný pre človeka', moduleWidth: 'Šírka modulu', + symbology: 'Symbolika', + segments: 'Segmenty na riadok', }, planet: { content: 'Obsah', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 4dd0fb6d..e8d7391a 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -305,6 +305,8 @@ const sl = { height: 'Višina (pike)', printInterpretation: 'Berljivo za človeka', moduleWidth: 'Širina modula', + symbology: 'Simbologija', + segments: 'Segmenti na vrstico', }, planet: { content: 'Vsebina', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 16d58492..ce43c7ab 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -305,6 +305,8 @@ const sr = { height: 'Висина (тачке)', printInterpretation: 'Читљиво', moduleWidth: 'Ширина модула', + symbology: 'Симбологија', + segments: 'Сегменти по реду', }, planet: { content: 'Садржај', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 98268ea5..15022c1f 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -305,6 +305,8 @@ const sv = { height: 'Höjd (punkter)', printInterpretation: 'Läsbar', moduleWidth: 'Modulbredd', + symbology: 'Symbolik', + segments: 'Segment per rad', }, planet: { content: 'Innehåll', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 26ab530f..116427f3 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -305,6 +305,8 @@ const tr = { height: 'Yükseklik (nokta)', printInterpretation: 'Okunabilir', moduleWidth: 'Modül genişliği', + symbology: 'Semboloji', + segments: 'Satır başına segment', }, planet: { content: 'İçerik', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 13a69d77..a6dee33c 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -305,6 +305,8 @@ const zhHans = { height: '高度(点)', printInterpretation: '可读文字', moduleWidth: '模块宽度', + symbology: '符号体系', + segments: '每行段数', }, planet: { content: '内容', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 80cf79ce..d04a2a5f 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -305,6 +305,8 @@ const zhHant = { height: '高度(點)', printInterpretation: '可讀文字', moduleWidth: '模組寬度', + symbology: '符號體系', + segments: '每行段數', }, planet: { content: '內容', diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index 022d53ba..ade94d8e 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -1,23 +1,131 @@ -import { createBarcode1D } from "./barcode1d"; -export type { Barcode1DProps as Gs1DatabarProps } from "./barcode1d"; - -export const gs1databar = createBarcode1D({ - label: "GS1 Databar", - icon: "GS1", - defaultContent: "0112345678901", - hasCheckDigit: false, - localeKey: "gs1databar", +import type { ObjectTypeDefinition, LabelObjectBase } from '../types/ObjectType'; +import { useT } from '../lib/useT'; +import { inputCls, labelCls } from '../components/Properties/styles'; +import { fieldPos, fdField } from './zplHelpers'; +import { filterContent } from './contentSpec'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; +import { + GS1_DATABAR_DEFAULT_SEGMENTS, + GS1_DATABAR_EXPANDED_SYMBOLOGIES, +} from '../lib/gs1'; + +export interface Gs1DatabarProps { + content: string; + moduleWidth: number; + symbology: 1 | 2 | 3 | 4 | 5 | 6 | 7; + segments?: number; + rotation: ZplRotation; +} + +// ZPL ^BR height parameter. Zebra firmware overrides this with each variant's +// intrinsic bar height for sym 1–5; only Expanded Stacked (sym 7) uses it as +// per-row height. Hardcoded because heightLocked: true forbids resizing. +const ZPL_HEIGHT_PLACEHOLDER = 100; + +// GS1 standard variant names. Kept in source rather than i18n because the spec +// defines them as proper nouns (matches the convention used for barcode-type +// labels like "GS1 Databar", "PDF417" in src/locales). +const SYMBOLOGY_LABELS: Record = { + 1: 'Omnidirectional', + 2: 'Truncated', + 3: 'Stacked', + 4: 'Stacked Omni', + 5: 'Limited', + 6: 'Expanded', + 7: 'Expanded Stacked', +}; + +export const gs1databar: ObjectTypeDefinition = { + label: 'GS1 Databar', + icon: 'GS1', group: 'code-1d', - contentSpec: { charset: '0-9' }, - // GS1 Databar Omnidirectional has a symbology-fixed height; Zebra/Labelary - // ignore the ^BR height parameter for this variant. Disabling resize and the - // height input keeps the designer honest about what affects the print. + defaultProps: { + content: '0112345678901', + moduleWidth: 2, + symbology: 1, + rotation: 'N', + }, + defaultSize: { width: 300, height: 120 }, heightLocked: true, - // ZPL ^BR has no HRI parameter — Labelary never prints text under the bars. - interpretationLocked: true, - zplCommand: (p) => { - // ^BR{orientation},{symbology},{magnification},{separator},{height},{segments} - // symbology 1 = omnidirectional - return `^BR${p.rotation},1,${p.moduleWidth},2,${p.height},1`; + + toZPL: (obj: LabelObjectBase & { props: Gs1DatabarProps }) => { + const p = obj.props; + // ^BRo,s,m,sep,h[,sg] — segments must only be present for Expanded Stacked (7). + // Including segments on sym 1–6 makes Labelary stack the symbol (wrong rendering). + const segs = p.symbology === 7 ? `,${p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS}` : ''; + return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,${ZPL_HEIGHT_PLACEHOLDER}${segs}${fdField(p.content)}`; + }, + + PropertiesPanel: ({ obj, onChange }) => { + const t = useT(); + const p = obj.props; + const loc = t.registry.gs1databar; + const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(p.symbology); + return ( +
+
+ + onChange({ + content: filterContent(e.target.value, { + charset: isExpanded ? '0-9A-Za-z()' : '0-9', + }), + })} + /> +
+ +
+ + onChange({ moduleWidth: Number(e.target.value) })} + /> +
+ +
+ + +
+ + {p.symbology === 7 && ( +
+ + { + const v = Number(e.target.value); + const even = v % 2 === 0 ? v : v + 1; + onChange({ segments: Math.max(2, Math.min(22, even)) }); + }} + /> +
+ )} + + onChange({ rotation })} + /> +
+ ); }, -}); +}; diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 5cac8bfb..ade14449 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -147,6 +147,10 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { "code93", "code11", // quiet zone narrower than Zebra "plessey", // different bar encoding algorithm ].includes(obj.type); + // GS1 Databar variants 2–7 use intrinsic heights that bwip-js maps differently + // than Zebra firmware. Width agrees, height diverges. Sym 1 (Omnidirectional) + // matches and is checked strictly; the others get ZPL-only validation. + const isGs1NonOmni = obj.type === "gs1databar" && obj.props.symbology !== 1; if (isEanUpc && !isQuarterRotated) { // Known discrepancy: Labelary reserves barHeight + EAN_TEXT_ZONE_DOTS (13 dots) @@ -183,7 +187,7 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { // codablock — bwip-js uses different encoding parameters than Zebra firmware. // hasBwipSizeMismatch — bwip-natural size diverges from Labelary (see above). // EAN/UPC and logmars heights are excluded — see isEanUpc/hasLogmarsTextZone above. - if (obj.type !== "codablock" && !hasBwipSizeMismatch) { + if (obj.type !== "codablock" && !hasBwipSizeMismatch && !isGs1NonOmni) { // Quarter-rotated EAN/UPC moves the EAN_TEXT_ZONE_DOTS guard extension // onto the width axis instead of the height axis, mirroring the upright // height adjustment. diff --git a/src/test/testModels.ts b/src/test/testModels.ts index 06e94971..ed1c5b58 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -324,7 +324,47 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, + props: { content: "0112345678901", moduleWidth: 2, symbology: 1, rotation: "N" }, + }, + barcode_gs1databar_truncated: { + id: "gs1_2", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 2, rotation: "N" }, + }, + barcode_gs1databar_stacked: { + id: "gs1_3", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 3, rotation: "N" }, + }, + barcode_gs1databar_stacked_omni: { + id: "gs1_4", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 4, rotation: "N" }, + }, + barcode_gs1databar_limited: { + id: "gs1_5", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 5, rotation: "N" }, + }, + barcode_gs1databar_expanded: { + id: "gs1_6", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901231", moduleWidth: 2, symbology: 6, rotation: "N" }, }, barcode_upce_standard: { id: "29", diff --git a/src/test/visualRegression.test.ts b/src/test/visualRegression.test.ts index 397c0ee9..43e0e786 100644 --- a/src/test/visualRegression.test.ts +++ b/src/test/visualRegression.test.ts @@ -56,8 +56,14 @@ describe("Visual Regression - bwip-js vs Labelary", () => { "barcode_code93_standard", // bwip-js code11 quiet zone is narrower than Zebra's; same issue as code93. "barcode_code11_standard", - // bwip-js GS1 DataBar stacking/finder-pattern differs from Zebra firmware. + // bwip-js GS1 DataBar stacking/finder-pattern differs from Zebra firmware + // for the multi-row variants. Sym 1 (Omnidirectional) matches. "barcode_gs1databar_standard", + "barcode_gs1databar_truncated", + "barcode_gs1databar_stacked", + "barcode_gs1databar_stacked_omni", + "barcode_gs1databar_limited", + "barcode_gs1databar_expanded", // 2D encoder discrepancies between bwip-js and Zebra firmware persist // through rotation. For QR specifically the rotation appears to shift // bwip's chosen mask, widening the diff vs. Labelary even though the diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_expanded.png b/tests/fixtures/labelary_images/barcode_gs1databar_expanded.png new file mode 100644 index 00000000..33e124ab Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_gs1databar_expanded.png differ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_limited.png b/tests/fixtures/labelary_images/barcode_gs1databar_limited.png new file mode 100644 index 00000000..aaa86a3b Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_gs1databar_limited.png differ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_stacked.png b/tests/fixtures/labelary_images/barcode_gs1databar_stacked.png new file mode 100644 index 00000000..92f95ffb Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_gs1databar_stacked.png differ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png b/tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png new file mode 100644 index 00000000..f3a6e0ed Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png differ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_standard.png b/tests/fixtures/labelary_images/barcode_gs1databar_standard.png index 67f0b98d..4bc648a3 100644 Binary files a/tests/fixtures/labelary_images/barcode_gs1databar_standard.png and b/tests/fixtures/labelary_images/barcode_gs1databar_standard.png differ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_truncated.png b/tests/fixtures/labelary_images/barcode_gs1databar_truncated.png new file mode 100644 index 00000000..62047615 Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_gs1databar_truncated.png differ diff --git a/tests/fixtures/labelary_images/fixtures.json b/tests/fixtures/labelary_images/fixtures.json index 391de5fe..6450174d 100644 --- a/tests/fixtures/labelary_images/fixtures.json +++ b/tests/fixtures/labelary_images/fixtures.json @@ -310,7 +310,7 @@ }, { "id": "barcode_gs1databar_standard", - "zpl_input": "^XA^BY2^FO50,50^BRN,1,2,2,100,1^FD0112345678901^FS^XZ", + "zpl_input": "^XA^BY2^FO50,50^BRN,1,2,2,100^FD0112345678901^FS^XZ", "expected_bounds": { "x": 50, "y": 50, @@ -428,6 +428,61 @@ "height": 190 }, "image_ref": "barcode_ean13_rot_B.png" + }, + { + "id": "barcode_gs1databar_truncated", + "zpl_input": "^XA^BY2^FO50,50^BRN,2,2,2,100^FD0112345678901^FS^XZ", + "expected_bounds": { + "x": 50, + "y": 50, + "width": 192, + "height": 26 + }, + "image_ref": "barcode_gs1databar_truncated.png" + }, + { + "id": "barcode_gs1databar_stacked", + "zpl_input": "^XA^BY2^FO50,50^BRN,3,2,2,100^FD0112345678901^FS^XZ", + "expected_bounds": { + "x": 50, + "y": 50, + "width": 100, + "height": 28 + }, + "image_ref": "barcode_gs1databar_stacked.png" + }, + { + "id": "barcode_gs1databar_stacked_omni", + "zpl_input": "^XA^BY2^FO50,50^BRN,4,2,2,100^FD0112345678901^FS^XZ", + "expected_bounds": { + "x": 50, + "y": 50, + "width": 100, + "height": 144 + }, + "image_ref": "barcode_gs1databar_stacked_omni.png" + }, + { + "id": "barcode_gs1databar_limited", + "zpl_input": "^XA^BY2^FO50,50^BRN,5,2,2,100^FD0112345678901^FS^XZ", + "expected_bounds": { + "x": 50, + "y": 50, + "width": 148, + "height": 20 + }, + "image_ref": "barcode_gs1databar_limited.png" + }, + { + "id": "barcode_gs1databar_expanded", + "zpl_input": "^XA^BY2^FO50,50^BRN,6,2,2,100^FD0112345678901231^FS^XZ", + "expected_bounds": { + "x": 50, + "y": 50, + "width": 266, + "height": 68 + }, + "image_ref": "barcode_gs1databar_expanded.png" } ] } \ No newline at end of file diff --git a/tests/fixtures/testCases.ts b/tests/fixtures/testCases.ts index a88e99b2..edbafbae 100644 --- a/tests/fixtures/testCases.ts +++ b/tests/fixtures/testCases.ts @@ -184,10 +184,44 @@ export const testCases: TestCase[] = [ }, { id: "barcode_gs1databar_standard", - zpl_input: "^XA^BY2^FO50,50^BRN,1,2,2,100,1^FD0112345678901^FS^XZ", + zpl_input: "^XA^BY2^FO50,50^BRN,1,2,2,100^FD0112345678901^FS^XZ", expected_bounds: { x: 50, y: 50, width: 192, height: 66 }, image_ref: "barcode_gs1databar_standard.png", }, + { + id: "barcode_gs1databar_truncated", + zpl_input: "^XA^BY2^FO50,50^BRN,2,2,2,100^FD0112345678901^FS^XZ", + expected_bounds: { x: 50, y: 50, width: 192, height: 26 }, + image_ref: "barcode_gs1databar_truncated.png", + }, + { + id: "barcode_gs1databar_stacked", + zpl_input: "^XA^BY2^FO50,50^BRN,3,2,2,100^FD0112345678901^FS^XZ", + expected_bounds: { x: 50, y: 50, width: 100, height: 28 }, + image_ref: "barcode_gs1databar_stacked.png", + }, + { + id: "barcode_gs1databar_stacked_omni", + zpl_input: "^XA^BY2^FO50,50^BRN,4,2,2,100^FD0112345678901^FS^XZ", + expected_bounds: { x: 50, y: 50, width: 100, height: 144 }, + image_ref: "barcode_gs1databar_stacked_omni.png", + }, + { + id: "barcode_gs1databar_limited", + zpl_input: "^XA^BY2^FO50,50^BRN,5,2,2,100^FD0112345678901^FS^XZ", + expected_bounds: { x: 50, y: 50, width: 148, height: 20 }, + image_ref: "barcode_gs1databar_limited.png", + }, + { + id: "barcode_gs1databar_expanded", + zpl_input: "^XA^BY2^FO50,50^BRN,6,2,2,100^FD0112345678901231^FS^XZ", + expected_bounds: { x: 50, y: 50, width: 266, height: 68 }, + image_ref: "barcode_gs1databar_expanded.png", + }, + // Note: Expanded Stacked (symbology 7) is intentionally not Labelary-validated. + // bwip-js requires "(01)…" parens-AI input; Labelary's ^BR sym 7 silently rejects + // that format and renders an empty PNG, so dimensions cannot be cross-validated. + // The ZPL roundtrip is covered by a unit test in zplGenerator.test.ts. { id: "barcode_upce_standard", zpl_input: "^XA^BY2^FO50,50^B9N,100,N,N^FD012345^FS^XZ",